@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
@@ -3,152 +3,1810 @@
3
3
  */
4
4
  import { Host, h } from "@stencil/core";
5
5
  import { DiscountType, OfferType, } from "./interfaces";
6
+ import { getCartFreeGiftItems, getCartItemCount, hasAuthoritativeCartData, refreshCartFreeGifts, } from "../../shared/cart-free-gifts";
7
+ import { CART_UPDATE_COALESCE_MS } from "./cart-update-window";
8
+ import { NO_STAGE_REACHED, OfferStageMeasure, computeOfferStageProgress, getRemainingToStage, isUsableThreshold, sortByThreshold, } from "../../shared/offer-stages";
9
+ /** Root of every translation key this component uses. */
10
+ const LANG = "blocks.cart_offer";
11
+ /**
12
+ * Hard ceiling on the `salla.onReady()` wait.
13
+ *
14
+ * A REJECTED bootstrap was always handled; a NEVER-SETTLING one was not — and an
15
+ * unbounded await gates the first `refresh()`, i.e. the entire component. Timing out
16
+ * costs at worst one early request, which `refresh()` already treats as a retryable,
17
+ * non-terminal condition.
18
+ */
19
+ const ON_READY_TIMEOUT_MS = 3000;
20
+ /**
21
+ * Absolute ceiling on the loading skeleton.
22
+ *
23
+ * The skeleton is a TRANSIENT state, never a resting one: past this point something has
24
+ * gone wrong that no future code change should be able to turn back into a permanent
25
+ * shimmer, and rendering nothing is strictly better than a spinner that never stops.
26
+ */
27
+ const SKELETON_TIMEOUT_MS = 8000;
28
+ /**
29
+ * Back-off for a cart lookup that could not ANSWER — one entry per retry, so the length of
30
+ * the list is the bound.
31
+ *
32
+ * An unknown count renders nothing, and nothing else re-asks: every other lookup hangs off
33
+ * `refresh()` or `cart::updated`, and a shopper who is just looking at their cart triggers
34
+ * neither. Without this a connection that drops for a second at mount hides a live offer
35
+ * for the whole page view. BOUNDED, because a lookup that keeps failing is not going to be
36
+ * rescued by asking for ever — after the last entry "hidden" is the final answer until the
37
+ * cart changes.
38
+ */
39
+ const CART_LOOKUP_RETRY_DELAYS_MS = [1000, 2000, 4000];
40
+ /**
41
+ * How a cart ITEM ROW identifies itself, in document order.
42
+ *
43
+ * TWO independent markers on purpose: `data-testid` is the platform's own convention and
44
+ * `form[id^="item-"]` is what every Twilight cart template has always emitted, so a theme
45
+ * that drops one still answers the other. Deliberately NOT a column class — see
46
+ * `CART_COLUMN_SELECTOR`.
47
+ */
48
+ const CART_ITEM_SELECTOR = '[data-testid^="store-cart-item-"], form[id^="item-"]';
49
+ /**
50
+ * The FALLBACK column selectors, used only when no item row exists — i.e. an EMPTY cart,
51
+ * where the column is real but has nothing in it.
52
+ *
53
+ * `.main-content` is raed's own class and means nothing in another theme, which is exactly
54
+ * why it can never be the primary strategy: it is consulted after the item-row lookup has
55
+ * already failed, and its failure is non-fatal.
56
+ */
57
+ const CART_COLUMN_SELECTOR = '.main-content, [data-cart-items]';
58
+ /**
59
+ * How far UP from the host we look for the row that holds the items column. The theme
60
+ * mounts the host as a direct sibling of that row (level 0); the extra levels only cover a
61
+ * theme that wraps the host in a div or two.
62
+ */
63
+ const RELOCATION_ANCESTOR_LIMIT = 3;
64
+ /**
65
+ * Bounded wait for a cart-items column the HTML parser has not produced YET.
66
+ *
67
+ * `<salla-conditional-offer>` is parsed BEFORE the two-column row that follows it, so on a
68
+ * page where the bundle is already registered the first attempt legitimately runs against a
69
+ * DOM with no cart in it. Past this deadline the full-width banner is the final answer.
70
+ */
71
+ const RELOCATION_WATCH_MS = 5000;
72
+ /**
73
+ * Clear space kept between the ⓘ popover and the viewport edge when it is nudged back
74
+ * inside. Purely cosmetic — one gutter, both edges, both directions.
75
+ */
76
+ const TOOLTIP_VIEWPORT_GUTTER_PX = 8;
77
+ /**
78
+ * How close the caret may get to the popover's own rounded corner.
79
+ *
80
+ * The caret chases the ⓘ icon; once the box has been nudged away from a viewport edge the
81
+ * icon can end up beyond the box's corner radius, and a caret drawn there detaches from
82
+ * the panel. Past this inset it stops following and the popover's EDGE points at the icon
83
+ * instead.
84
+ */
85
+ const TOOLTIP_CARET_INSET_PX = 16;
86
+ /** Source of the per-instance suffix on every `id` this component renders — see `instanceId`. */
87
+ let instanceCount = 0;
88
+ /**
89
+ * Every shopper-facing sentence frame comes from the platform's `blocks.cart_offer.*`
90
+ * translations (read through `trans`); the reward wording itself is server-rendered as
91
+ * `discounts[].reward_label`.
92
+ *
93
+ * These are the fallbacks for a storefront locale whose platform dictionary has no
94
+ * `blocks.cart_offer` block yet: the platform resolves a locale's dictionary as a whole
95
+ * file, with no per-key fallback, so without them such a storefront would render a banner
96
+ * with no text at all.
97
+ */
98
+ const EN_FALLBACKS = {
99
+ badges_summary: ':completed of :total offers unlocked',
100
+ congrats: 'Congratulations! You’ve unlocked all rewards in this promotion',
101
+ details_title: 'Offer details',
102
+ free_gift: 'Free Gift',
103
+ free_products_title: 'Free products selected from the offer',
104
+ guidance_conditions_unmet: 'Your cart doesn’t meet the conditions for the next reward yet',
105
+ guidance_next: 'Add :remaining and get :reward',
106
+ guidance_next_generic: 'Add :remaining to unlock the next reward',
107
+ manage_offer: 'Manage offer',
108
+ tier_requirement: 'Spend :threshold and get :reward',
109
+ tier_requirement_count: 'Buy :threshold and get :reward',
110
+ tier_requirement_count_generic: 'Buy :threshold to unlock this offer',
111
+ tier_requirement_generic: 'Spend :threshold to unlock this offer',
112
+ tier_title: 'Offer (:number)',
113
+ };
114
+ /** The gift price label reuses the platform's existing "free of charge" string. */
115
+ const FREE_LABEL_KEY = 'pages.checkout.free_charge';
116
+ const FREE_LABEL_FALLBACK = 'Free';
117
+ /**
118
+ * Escape a merchant-controlled string before it is substituted into a translation
119
+ * template that we then render with `innerHTML`.
120
+ *
121
+ * `innerHTML` is unavoidable for the guidance and tier lines: their amount halves carry
122
+ * `salla.money()` markup (`<i class=sicon-sar>` when the store uses the SAR symbol).
123
+ * `lang.js` fills placeholders with a plain string replace and escapes nothing, and
124
+ * `reward_label` is produced by `theme_trans()`, i.e. merchant-overridable copy — so the
125
+ * untrusted half has to be escaped here.
126
+ */
127
+ function escapeHtml(value) {
128
+ const holder = document.createElement('div');
129
+ holder.textContent = value ?? '';
130
+ return holder.innerHTML;
131
+ }
132
+ /**
133
+ * The ⓘ popover's prose, read from `details.rules` and from NOWHERE else.
134
+ *
135
+ * `CartOffersTransformer::transformConditionalOffer` sends the merchant's authored offer
136
+ * message under `details.rules` and holds `description` at `''` on purpose: the SHIPPED
137
+ * `salla-conditional-offer` renders `description` into its subtitle today, so carrying the
138
+ * message there would have rewritten live storefront copy on every store the moment the
139
+ * backend deployed. One value, one key — so this must NOT fall back to `description`, or
140
+ * the two sources diverge again the first time some other producer fills it.
141
+ *
142
+ * Everything absent-ish collapses to `null`, i.e. render no subtitle at all rather than an
143
+ * empty element or the string "undefined":
144
+ *
145
+ * - key MISSING — an older Store deploy sends no `rules` at all;
146
+ * - EMPTY STRING — a live store payload sent `rules: ''` verbatim,
147
+ * because `$offer->message` was null and the transformer short-circuits to `''`;
148
+ * - WHITESPACE — `strip_tags()` on a message that was only markup leaves `" "`;
149
+ * - an ARRAY — the `SpecialOffer` model's own `rules` column is a rule LIST, so a future
150
+ * producer confusing the two would hand us one; keep the usable strings, drop the rest;
151
+ * - anything else (number, object, null) is not prose and is discarded.
152
+ */
153
+ function readOfferRules(offer) {
154
+ const rules = offer?.details?.rules;
155
+ if (typeof rules === 'string') {
156
+ const trimmed = rules.trim();
157
+ return trimmed === '' ? null : trimmed;
158
+ }
159
+ if (Array.isArray(rules)) {
160
+ const joined = rules
161
+ .filter((entry) => typeof entry === 'string')
162
+ .map(entry => entry.trim())
163
+ .filter(entry => entry !== '')
164
+ .join(' ');
165
+ return joined === '' ? null : joined;
166
+ }
167
+ return null;
168
+ }
169
+ /*
170
+ * ---------------------------------------------------------------------------
171
+ * Inline icons
172
+ * ---------------------------------------------------------------------------
173
+ * Three inline SVGs replace the check, lock and information icon-font glyphs.
174
+ * (The old class names are deliberately not spelled out here: the build keeps this
175
+ * comment in a dev bundle, and a stray literal would defeat the grep that proves the
176
+ * glyphs are gone.) They are INLINED AS JSX rather than imported through
177
+ * `stencil-inline-svg` + `innerHTML` (the other pattern in this repo) for three
178
+ * reasons: no build-plugin dependency in the one component that needs it, no
179
+ * asset path to resolve once this ships as a CDN-hosted bundle, and — because
180
+ * the badge renders once PER TIER — no duplicated `id` / `clip-path` in the DOM.
181
+ *
182
+ * Three invariants, all load-bearing:
183
+ *
184
+ * 1. `fill="currentColor"`. The met badge is teal and the unmet badge is grey
185
+ * purely through `tier-status-met` / `tier-status-unmet`, which set `color`.
186
+ * Hardcoding a fill here would freeze both states to one colour.
187
+ *
188
+ * 2. NO `width` / `height` attribute. The three sources have DIFFERENT intrinsic
189
+ * sizes (10 / 14 / 16). Left on the element those would leak straight into the
190
+ * layout and resize the badge each source was dropped into. Size comes from the
191
+ * caller's utility class instead, so the chip and trigger keep the exact box they
192
+ * have today. Beware when picking that size: NONE of the three glyphs fills its
193
+ * viewBox. The check disc spans 83.3% of its 10-unit box, the lock 75% wide and
194
+ * 83.3% tall of its 16-unit box, the info disc 83.3% of its 14-unit box. Size by the
195
+ * ink you want to SEE, not by the box.
196
+ *
197
+ * 3. NO `id` / `clip-path` / `clipPath`. A check icon source that
198
+ * wrapped its path in `<g clip-path="url(#clip0_...)">` shipped NO
199
+ * `<defs>` defining that clipPath — an unresolvable clip-path reference makes
200
+ * the browser drop the element, so the icon rendered as nothing. The wrapper is
201
+ * stripped and only the `<path>` kept. (`information-fill.svg` and
202
+ * `lock-fill.svg` were checked and are clean — no `<g>`, no ids, no defs.)
203
+ *
204
+ * All three are decorative: the accessible name lives on the ancestor (the badges
205
+ * row's `role="img"` + "N of M" label, and the ⓘ trigger button's `aria-label`),
206
+ * so each carries `aria-hidden` + `focusable="false"` (the latter for IE/Edge
207
+ * legacy, which otherwise puts SVGs in the tab order).
208
+ */
209
+ /** Filled circle enclosing a check — the MET tier status. Source: 10x10. */
210
+ 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" })));
211
+ /** Filled padlock — the UNMET tier status. Source: 16x16. */
212
+ 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" })));
213
+ /** Filled circle enclosing an `i` — the ⓘ tooltip trigger. Source: 14x14. */
214
+ 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" })));
6
215
  export class SallaConditionalOffer {
7
216
  constructor() {
8
217
  this.offer = null;
9
- this.products = {};
10
218
  this.isLoading = false;
11
219
  this.canRender = true;
220
+ /** Free-gift cart LINES belonging to this offer — the "ادارة العرض" card. */
221
+ this.gifts = [];
222
+ /**
223
+ * How many LINES the cart holds — `null` while that is genuinely UNKNOWN.
224
+ *
225
+ * TRI-STATE on purpose, and the whole of the empty-cart fix rests on it. `0` renders
226
+ * NOTHING; `null` renders normally. Collapsing the two would hide the banner on every
227
+ * populated cart for as long as the first `details()` request is in flight, which is
228
+ * the flicker this must not trade one bug for.
229
+ */
230
+ this.cartItemCount = null;
231
+ /** The tooltip is open while hovered OR while pinned by a click (the mobile path). */
232
+ this.isTooltipHovered = false;
233
+ this.isTooltipPinned = false;
234
+ /** "ادارة العرض" — open by default, the gifts card starts expanded. */
235
+ this.isGiftsOpen = true;
236
+ /**
237
+ * What makes this instance's `id`s its OWN. The banner can legitimately mount more than
238
+ * once on a page, and with fixed ids the second instance's `aria-describedby` /
239
+ * `aria-controls` resolve to the FIRST one's popover and gifts card.
240
+ */
241
+ this.instanceId = ++instanceCount;
242
+ /**
243
+ * Monotonic generation counter guarding `refresh()`. Two cart mutations in quick succession issue
244
+ * two `offers()` requests, and nothing guarantees they SETTLE in the order they were
245
+ * sent — without this, a slow first response can land after a fast second one and
246
+ * render a `current_value` that is already stale.
247
+ */
248
+ this.refreshGeneration = 0;
249
+ /** The same guard for the independent gift-lines lookup. */
250
+ this.giftsGeneration = 0;
251
+ /**
252
+ * The open coalescing window: the timer that will run the ONE refresh pass a burst of
253
+ * `cart::updated` events is entitled to. `null` whenever no burst is in flight.
254
+ *
255
+ * The generation guards above are untouched by this and still do their own job — the
256
+ * window collapses events that have not been ASKED about yet, they discard answers to
257
+ * questions that have since been superseded, and a burst that straddles the window
258
+ * (an event arriving while a pass is already in flight) needs both.
259
+ */
260
+ this.cartRefreshTimer = null;
261
+ /**
262
+ * "A burst has been seen and its pass has not run yet."
263
+ *
264
+ * Survives a disconnect on purpose, exactly as `skeletonDeadline` does: a theme that
265
+ * MOVES the host in the DOM mid-burst (disconnect + reconnect, same instance) would
266
+ * otherwise drop the trailing pass and rest on pre-burst data forever, since nothing
267
+ * re-runs `refresh()` but another `cart::updated`. `connectedCallback` re-arms it.
268
+ */
269
+ this.cartRefreshPending = false;
270
+ /**
271
+ * Set once `load()` has asked for `cart::updated`, and never cleared: it is what lets
272
+ * `connectedCallback` tell a RECONNECT (put the listener back) from the first connect,
273
+ * which runs before `componentWillLoad` and must leave the subscription to it.
274
+ */
275
+ this.wantsCartUpdates = false;
276
+ /** Whether `cartUpdatedHandler` is on `cart::updated` RIGHT NOW — the double-registration guard. */
277
+ this.isListeningToCartUpdates = false;
278
+ /** The armed cart-lookup retry; `null` whenever none is. See `CART_LOOKUP_RETRY_DELAYS_MS`. */
279
+ this.cartLookupRetryTimer = null;
280
+ /** How many of those retries this episode has SPENT — the bound. */
281
+ this.cartLookupRetries = 0;
282
+ /** "A retry is owed" — survives a disconnect for the same reason `cartRefreshPending` does. */
283
+ this.cartLookupRetryPending = false;
284
+ /**
285
+ * Set by the ONE branch of `refresh()` that returns without asking for the offer: the
286
+ * cart was reported empty.
287
+ *
288
+ * That branch is right whenever the report is, and unrecoverable whenever it is not —
289
+ * nothing re-runs `refresh()` except `cart::updated`, which a shopper who never
290
+ * touches the cart never emits. So the branch now leaves this behind, and the cart
291
+ * lookup it kicks off clears it by re-running `refresh()` if the count comes back
292
+ * `>= 1`. See both sites; the latch is what keeps that to a recovery rather than a
293
+ * second offer request on every load.
294
+ */
295
+ this.deferredByEmptyCart = false;
296
+ /** Set by `disconnectedCallback`, so a late response cannot set state on a dead host. */
297
+ this.isDisconnected = false;
298
+ /** Backstop timer that guarantees the skeleton is never a resting state. */
299
+ this.skeletonWatchdog = null;
300
+ /**
301
+ * WHEN the current skeleton must be gone by — the authority, with `skeletonWatchdog`
302
+ * demoted to a mere handle.
303
+ *
304
+ * A timer handle answers "is a timer pending", which is not the question. The question
305
+ * is "is this skeleton past its deadline", and the two come apart precisely when it
306
+ * matters: a handle that was cleared, consumed, or never armed reads exactly like one
307
+ * that is still counting down. Keeping the deadline as a timestamp means the watchdog
308
+ * can always be REBUILT from state — which is what `ensureSkeletonIsWatched` does, on
309
+ * every render, so a skeleton on screen can never be one nothing is watching.
310
+ *
311
+ * `0` means "not loading"; anything else is an absolute `Date.now()` deadline.
312
+ */
313
+ this.skeletonDeadline = 0;
314
+ /** Latched once the host sits in the items column, so it can never be moved twice. */
315
+ this.hasRelocated = false;
316
+ /** `componentDidRender` fires BEFORE the first `componentDidLoad`; this orders them. */
317
+ this.hasLoaded = false;
318
+ /** Observer waiting for a column that the parser has not emitted yet. */
319
+ this.relocationObserver = null;
320
+ /** Deadline that bounds that wait. */
321
+ this.relocationWatchTimer = null;
322
+ /** Set when the wait expired: stop re-arming, keep the cheap per-render retry. */
323
+ this.relocationWatchExpired = false;
324
+ /** "No column found" is said ONCE per instance, not on every render. */
325
+ this.relocationLogged = false;
326
+ /**
327
+ * The `cart::updated` payload is ignored for everything EXCEPT the item count — it may
328
+ * legitimately arrive with no `items` at all (`api.cart.latest` is not in
329
+ * `CartPresenter::$routesWithItems`). Refetch and take the server's `current_value`.
330
+ *
331
+ * `count` is the one field that is safe to read here, and reading it is what fixes the
332
+ * live removal path. `SallaCartEvent.updated()` dispatches `{ ...data.cart }`
333
+ * (twilight/src/event/cart.js:114) and writes `count` from that same object into
334
+ * `cart.summary` one line above it, so the field is first-class — unlike `items`, which
335
+ * genuinely is not. Taking it SYNCHRONOUSLY, before any request goes out, is the point:
336
+ * see `readCartCountFromEvent`.
337
+ *
338
+ * Two independent refetches fan out from here and are NOT chained: the offer payload
339
+ * (thresholds + reward labels) and the gift cart LINES. The gift lookup is shared
340
+ * process-wide (`shared/cart-free-gifts`), so the item-offer badges, the summary card
341
+ * and this banner still cost ONE `details()` request between them per mutation.
342
+ *
343
+ * Both of those refetches are COALESCED — see `CART_UPDATE_COALESCE_MS`. Only the
344
+ * count read below is per-event.
345
+ *
346
+ * ONE reference per instance rather than an inline closure, because `salla.event.off`
347
+ * can only remove the exact function `on` was given.
348
+ */
349
+ this.cartUpdatedHandler = (payload) => {
350
+ // FIRST, and synchronously — see `readCartCountFromEvent`. `refresh()` reads
351
+ // `cartItemCount` to decide whether to raise a skeleton at all, so the count has
352
+ // to be current before it runs, not a request later. This is also what keeps the
353
+ // coalescing window below invisible to the shopper on the path that matters:
354
+ // emptying the cart hides the banner on THIS line, not when the window closes.
355
+ this.readCartCountFromEvent(payload);
356
+ // The cart moved, so the retry episode for the OLD cart is over; the pass below
357
+ // re-asks, and opens a new one if that cannot answer either.
358
+ this.clearCartLookupRetry();
359
+ /**
360
+ * Raises `cartRefreshPending` on THIS line, and that — not the delayed pass — is
361
+ * what voids every cart answer older than this event: `loadCart()` publishes
362
+ * nothing while it is up. Left valid for the length of the window, a lookup
363
+ * landing inside it wrote its old positive count over the `0` this handler had
364
+ * just read, and the banner came back over an emptied cart.
365
+ */
366
+ this.scheduleCartRefresh();
367
+ };
12
368
  }
369
+ /**
370
+ * SYNCHRONOUS on purpose — an `async` version of this hook is what made the whole
371
+ * component invisible.
372
+ *
373
+ * Stencil awaits whatever `componentWillLoad` returns BEFORE it calls `render()` for
374
+ * the first time (`dispatchHooks` → `enqueue(maybePromise, () => updateComponent(...))`).
375
+ * An `async componentWillLoad` chaining round trips inside that promise leaves the host
376
+ * element literally empty for the whole chain, and FOREVER if the last of them never
377
+ * settles.
378
+ *
379
+ * Same posture as `salla-cart-summary-card` ("Sync: returning a promise would make
380
+ * Stencil defer first paint"): paint now, fill in as the data lands.
381
+ */
13
382
  componentWillLoad() {
14
- salla.onReady().then(() => {
15
- if (!salla.config.get('store.features')?.includes('conditional-offer') || (salla.storage.get("cart") !== '' && !salla.storage.get("cart")?.summary?.count)) {
16
- throw new Error('feature or cart object does not existed');
383
+ /**
384
+ * The escape hatch is no longer something a single well-placed call has to get
385
+ * right. `ensureSkeletonIsWatched()` runs from `startLoading()`, from
386
+ * `connectedCallback` and from EVERY `componentDidRender`, so the watchdog is a
387
+ * function of state rather than of call order: whatever raises the skeleton, the
388
+ * render that paints it re-establishes the deadline behind it.
389
+ *
390
+ * Kept here all the same, as the first statement and ahead of anything that can
391
+ * throw, for the case where `isEnabled()` is already true — it costs one boolean
392
+ * read when it is not.
393
+ */
394
+ this.ensureSkeletonIsWatched();
395
+ /**
396
+ * The whole body is guarded. A synchronous throw here is invisible — Stencil still
397
+ * renders the component after a throwing `componentWillLoad` — and it has now cost
398
+ * two rounds, so it is caught and logged explicitly rather than trusted not to
399
+ * happen.
400
+ */
401
+ try {
402
+ if (this.isEnabled())
403
+ this.startLoading();
404
+ /**
405
+ * `.catch` is LOAD-BEARING. `load()` is a floating promise — `componentWillLoad`
406
+ * must stay synchronous (see above) and cannot await it — so anything throwing
407
+ * inside it becomes an unhandled rejection: invisible, and fatal, because
408
+ * `refresh()` is the only thing that lowers `isLoading` in the happy path.
409
+ *
410
+ * RECOVERY BEFORE LOGGING, here and everywhere else in this file — see `safeLog`.
411
+ */
412
+ this.load().catch(error => {
413
+ this.stopLoading();
414
+ this.safeLog('load() failed', error);
415
+ });
416
+ }
417
+ catch (error) {
418
+ this.stopLoading();
419
+ this.safeLog('componentWillLoad threw synchronously', error);
420
+ }
421
+ }
422
+ /**
423
+ * Logging that CANNOT throw.
424
+ *
425
+ * `salla.logger.warn` is not safe to call during init: `base/src/logger.ts` opens every
426
+ * call with `if (!salla.config.isDebug()) return;`, so it raises a TypeError whenever
427
+ * `salla.config` has not been installed yet — precisely the window `componentWillLoad`
428
+ * runs in. Every recovery path in this component used to log BEFORE it recovered, so a
429
+ * single throw in here silently skipped the recovery it was announcing.
430
+ */
431
+ safeLog(message, error) {
432
+ // The component name is passed as its OWN argument rather than interpolated into
433
+ // `message`: the format string stays a static literal, which keeps the caller's
434
+ // text out of the formatter.
435
+ try {
436
+ salla.logger.warn('salla-conditional-offer::', message, error);
437
+ }
438
+ catch (loggerFailure) {
439
+ /* the SDK logger is the only sink — never let logging break the caller */
440
+ }
441
+ }
442
+ /**
443
+ * Cleared on RECONNECT, not just set on disconnect. `componentWillLoad` runs once per
444
+ * instance, so an element that a theme moves in the DOM (disconnect + reconnect keeps
445
+ * the same instance) would otherwise stay permanently latched as "dead" and stop
446
+ * updating on every later `cart::updated`.
447
+ */
448
+ connectedCallback() {
449
+ this.isDisconnected = false;
450
+ /**
451
+ * `disconnectedCallback` cleared the watchdog, and `componentWillLoad` does NOT run
452
+ * again on reconnect — so a host the theme moves in the DOM while still loading
453
+ * would come back with the skeleton up and no timer behind it. The deadline
454
+ * survives the move, so what is rebuilt here is only the timer.
455
+ */
456
+ this.ensureSkeletonIsWatched();
457
+ /**
458
+ * A host that was genuinely REMOVED gave its listener up (see `disconnectedCallback`)
459
+ * and was deaf until now, so it takes the listener back and is OWED a pass for
460
+ * whatever it missed. Skipped on the first connect, where `load()` has not asked
461
+ * yet, and on a plain move, where the listener never left.
462
+ */
463
+ if (this.wantsCartUpdates && !this.isListeningToCartUpdates && this.subscribeToCartUpdates()) {
464
+ this.cartRefreshPending = true;
465
+ }
466
+ /**
467
+ * Same contract as the watchdog above: the TIMER did not survive the move but the
468
+ * fact that a refresh is owed did, so it is re-armed here rather than lost. A host
469
+ * moved between the last `cart::updated` of a burst and the end of its window must
470
+ * still converge on that burst's cart state.
471
+ */
472
+ if (this.cartRefreshPending)
473
+ this.scheduleCartRefresh();
474
+ // ...and the same again for a cart-lookup retry that was still counting down.
475
+ if (this.cartLookupRetryPending)
476
+ this.scheduleCartLookupRetry();
477
+ }
478
+ disconnectedCallback() {
479
+ this.isDisconnected = true;
480
+ /**
481
+ * The timer goes; `cartRefreshPending` deliberately stays — see `connectedCallback`.
482
+ */
483
+ if (this.cartRefreshTimer !== null) {
484
+ clearTimeout(this.cartRefreshTimer);
485
+ this.cartRefreshTimer = null;
486
+ }
487
+ // Likewise: the timer goes, `cartLookupRetryPending` stays.
488
+ if (this.cartLookupRetryTimer !== null) {
489
+ clearTimeout(this.cartLookupRetryTimer);
490
+ this.cartLookupRetryTimer = null;
491
+ }
492
+ /**
493
+ * The LISTENER goes too, but only for a host that is really gone. This hook fires
494
+ * synchronously on EVERY removal — our own relocation and a theme moving the host
495
+ * included — and those have reconnected by the time the microtask runs, so they
496
+ * keep the listener and miss nothing. Same posture as `salla-cart-summary-card`.
497
+ */
498
+ Promise.resolve().then(() => {
499
+ if (this.isDisconnected)
500
+ this.unsubscribeFromCartUpdates();
501
+ });
502
+ /**
503
+ * The TIMER goes; the DEADLINE stays. A host the theme moves in the DOM must come
504
+ * back to the deadline it already had rather than to a fresh full window — and
505
+ * `clearSkeletonWatchdog()` resets both, so it is deliberately not used here.
506
+ */
507
+ if (this.skeletonWatchdog !== null) {
508
+ clearTimeout(this.skeletonWatchdog);
509
+ this.skeletonWatchdog = null;
510
+ }
511
+ this.stopWatchingForItemsColumn();
512
+ }
513
+ componentDidLoad() {
514
+ this.hasLoaded = true;
515
+ this.relocateIntoItemsColumn();
516
+ }
517
+ /**
518
+ * Whether the host currently puts anything on the page.
519
+ *
520
+ * Gates the relocation: a host that renders nothing contributes no layout and must not
521
+ * be moved around the document either. It becomes true on the render that first paints
522
+ * real content, and `componentDidRender` runs inside the same frame as that render —
523
+ * before the browser paints it — so the move still lands ahead of the first visible
524
+ * pixel and there is no jump.
525
+ */
526
+ isShowingSomething() {
527
+ return this.canRender && this.hasCartItems();
528
+ }
529
+ /**
530
+ * Every later render re-attempts the move. Free once it has happened (one boolean
531
+ * read), and it is what covers the ordinary late-arrival case without any timer: the
532
+ * skeleton paints while the row is still being parsed, and the render that replaces it
533
+ * with the offer runs against a complete DOM.
534
+ */
535
+ componentDidRender() {
536
+ if (this.hasLoaded)
537
+ this.relocateIntoItemsColumn();
538
+ // After the popover exists and has a width, never before — it is measured here and
539
+ // adjusted with CSS custom properties, which do NOT re-enter render().
540
+ this.positionTooltip();
541
+ /**
542
+ * THE INVARIANT: a skeleton that has just been painted has a deadline behind it.
543
+ * Restoring it here — rather than trusting whichever branch raised `isLoading` to
544
+ * have remembered — is what makes a permanently stuck shimmer unreachable, however
545
+ * the loading state was entered. A no-op (one boolean read) whenever we are not
546
+ * loading, which is almost always.
547
+ */
548
+ this.ensureSkeletonIsWatched();
549
+ }
550
+ // ---------------------------------------------------------------------------------
551
+ // Relocation — the banner belongs above the CART ITEMS, not above the whole page
552
+ // ---------------------------------------------------------------------------------
553
+ /**
554
+ * Move the host into the cart-items column.
555
+ *
556
+ * The theme mounts this component ABOVE the two-column row (`cart.twig`), so it spans
557
+ * the items column AND the order summary; the design puts it above the ITEMS only,
558
+ * with the summary starting level with it. Theme changes are out of scope, so the
559
+ * component places itself.
560
+ *
561
+ * MOVING rather than re-sizing is deliberate. A moved host inherits whatever width the
562
+ * column has — at every breakpoint, in RTL and LTR, and on a resize — and on mobile,
563
+ * where the column is the only column, the move is a no-op visually, which is exactly
564
+ * the mobile layout. A hard-coded `calc(100% - 384px)` is raed's number and would
565
+ * be wrong in any other theme.
566
+ *
567
+ * NEVER throws, and never leaves the component unrendered: a cart whose column cannot
568
+ * be identified keeps the full-width banner. That is a degraded LAYOUT, not a degraded
569
+ * component.
570
+ */
571
+ relocateIntoItemsColumn() {
572
+ if (this.hasRelocated)
573
+ return;
574
+ // Nothing on screen yet: no host to place, and nothing that could jump. Retried on
575
+ // every later render, so the move happens on the one that first shows content.
576
+ if (!this.isShowingSomething())
577
+ return;
578
+ try {
579
+ const host = this.hostEl;
580
+ if (!host?.parentElement)
581
+ return;
582
+ const column = this.findItemsColumn(host);
583
+ if (!column) {
584
+ this.watchForItemsColumn();
585
+ return;
17
586
  }
18
- })
19
- .then(() => salla.api.cart.offers())
20
- .then(({ data }) => {
21
- this.offer = data.find(offer => offer.type === "conditional");
22
- if (!this.offer) {
23
- this.canRender = false;
587
+ /**
588
+ * Latched BEFORE the DOM write. Moving a connected element fires
589
+ * `disconnectedCallback` + `connectedCallback` SYNCHRONOUSLY and notifies the
590
+ * observer below, so everything that can re-enter here must already see "done".
591
+ */
592
+ this.hasRelocated = true;
593
+ this.stopWatchingForItemsColumn();
594
+ // Already exactly where it belongs — a theme that mounts it inside the column.
595
+ if (column === host.parentElement && column.firstElementChild === host)
24
596
  return;
597
+ column.insertBefore(host, column.firstChild);
598
+ }
599
+ catch (error) {
600
+ // A selector engine or a DOM that surprises us must not cost the banner. Latch
601
+ // so a strategy that throws is not retried on every subsequent render.
602
+ this.hasRelocated = true;
603
+ this.stopWatchingForItemsColumn();
604
+ this.safeLog('relocating the banner into the cart items column failed', error);
605
+ }
606
+ }
607
+ /**
608
+ * The cart-items column, found WITHOUT depending on any single theme's class names.
609
+ *
610
+ * From the host OUTWARDS: at each level look at the elements that FOLLOW it — the row
611
+ * is always a later sibling, never an ancestor — and inside each of them look for a
612
+ * cart ITEM row. The column is then that row's own child which contains the item, i.e.
613
+ * precisely the element whose width the banner should inherit.
614
+ *
615
+ * Scoping to following siblings is what stops an unrelated `form[id^="item-"]` (a
616
+ * mini-cart drawer, a footer widget) from being mistaken for the cart.
617
+ */
618
+ findItemsColumn(host) {
619
+ let node = host;
620
+ for (let level = 0; node && level <= RELOCATION_ANCESTOR_LIMIT; level++) {
621
+ for (const row of this.getFollowingSiblings(node)) {
622
+ /**
623
+ * The host is ALREADY beside the cart items, so its own parent is the
624
+ * column. Without this, the item row itself would be treated as the row to
625
+ * look inside and the banner would land inside a cart item.
626
+ */
627
+ if (node === host && this.matches(row, CART_ITEM_SELECTOR))
628
+ return host.parentElement;
629
+ const column = this.findColumnWithin(row, host);
630
+ if (column)
631
+ return column;
632
+ }
633
+ node = node.parentElement;
634
+ if (!node || node === document.body || node === document.documentElement)
635
+ break;
636
+ }
637
+ return null;
638
+ }
639
+ getFollowingSiblings(node) {
640
+ const siblings = [];
641
+ for (let sibling = node.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
642
+ siblings.push(sibling);
643
+ }
644
+ return siblings;
645
+ }
646
+ /** The child of `row` that holds the cart items, if `row` is the two-column row. */
647
+ findColumnWithin(row, host) {
648
+ const item = row.querySelector(CART_ITEM_SELECTOR);
649
+ const column = item ? this.climbToChildOf(item, row) : null;
650
+ /**
651
+ * The item markers are the PRIMARY signal because every theme emits them. The class
652
+ * list is the fallback for a cart that is legitimately EMPTY — the column exists,
653
+ * the item rows do not — and is never reached while an item row is present.
654
+ */
655
+ const found = column ?? row.querySelector(CART_COLUMN_SELECTOR);
656
+ if (!found || found === host || found.contains(host))
657
+ return null;
658
+ return found;
659
+ }
660
+ /**
661
+ * Walk up from `node` to the DIRECT CHILD of `ancestor` that contains it.
662
+ *
663
+ * Not "the parent of the item rows": a theme that nests them (`col > ul > li > form`)
664
+ * would hand back the `<ul>`, and a banner is not a list item. The row's own child is
665
+ * the column in every shape.
666
+ */
667
+ climbToChildOf(node, ancestor) {
668
+ let current = node;
669
+ while (current && current.parentElement && current.parentElement !== ancestor) {
670
+ current = current.parentElement;
671
+ }
672
+ return current?.parentElement === ancestor ? current : null;
673
+ }
674
+ matches(element, selector) {
675
+ return typeof element.matches === 'function' && element.matches(selector);
676
+ }
677
+ /**
678
+ * Wait for a column that does not exist YET, then stop waiting.
679
+ *
680
+ * Bounded on both sides: the observer is disconnected the moment the move succeeds, and
681
+ * a deadline retires it if it never does. Where there is no `MutationObserver` (older
682
+ * browsers, and the spec DOM) this degrades to the per-render retry in
683
+ * `componentDidRender`, which is the path that matters anyway.
684
+ */
685
+ watchForItemsColumn() {
686
+ if (this.hasRelocated || this.relocationWatchExpired || this.relocationObserver) {
687
+ return;
688
+ }
689
+ if (typeof MutationObserver === 'undefined') {
690
+ this.logNoItemsColumn();
691
+ return;
692
+ }
693
+ this.relocationObserver = new MutationObserver(() => this.relocateIntoItemsColumn());
694
+ this.relocationObserver.observe(document.documentElement, { childList: true, subtree: true });
695
+ this.relocationWatchTimer = setTimeout(() => {
696
+ this.relocationWatchTimer = null;
697
+ this.relocationWatchExpired = true;
698
+ this.stopWatchingForItemsColumn();
699
+ this.logNoItemsColumn();
700
+ }, RELOCATION_WATCH_MS);
701
+ }
702
+ stopWatchingForItemsColumn() {
703
+ if (this.relocationObserver) {
704
+ try {
705
+ this.relocationObserver.disconnect();
25
706
  }
26
- this.isLoading = true;
27
- this.offer.details.discounts.unshift(({ value: 0, min_spend: 0 }));
28
- return this.updateInitialOfferValue();
29
- }).then(() => {
30
- if (!this.offer)
707
+ catch (error) {
708
+ /* an observer that cannot be disconnected must not break a DOM move */
709
+ }
710
+ this.relocationObserver = null;
711
+ }
712
+ if (this.relocationWatchTimer !== null) {
713
+ clearTimeout(this.relocationWatchTimer);
714
+ this.relocationWatchTimer = null;
715
+ }
716
+ }
717
+ /** Said ONCE per instance: the full-width banner is a layout note, not a per-render alarm. */
718
+ logNoItemsColumn() {
719
+ if (this.relocationLogged)
720
+ return;
721
+ this.relocationLogged = true;
722
+ this.safeLog('no cart items column found — leaving the banner at full page width');
723
+ }
724
+ /**
725
+ * Guarantee that a raised skeleton has a live deadline behind it — idempotent, cheap,
726
+ * and called from EVERY path that can leave one on screen (`startLoading`,
727
+ * `connectedCallback`, and `componentDidRender`).
728
+ *
729
+ * This is the structural answer to "a skeleton with nothing watching it". The old shape
730
+ * armed the timer only inside `startLoading()`, and only when the HANDLE happened to be
731
+ * null — so any path that raised `isLoading` without going through that guard, or that
732
+ * left a stale-looking handle behind, produced a shimmer that nothing would ever end.
733
+ * Re-checking on every render means the invariant is restored by the very act of
734
+ * painting the skeleton, whatever raised it.
735
+ *
736
+ * The deadline is set ONCE per loading episode and never pushed out, so a stream of
737
+ * `cart::updated` events cannot extend a skeleton indefinitely.
738
+ */
739
+ ensureSkeletonIsWatched() {
740
+ if (!this.isLoading)
741
+ return;
742
+ // Already counting down towards a deadline that is still in the future.
743
+ if (this.skeletonWatchdog !== null)
744
+ return;
745
+ const now = Date.now();
746
+ if (this.skeletonDeadline === 0)
747
+ this.skeletonDeadline = now + SKELETON_TIMEOUT_MS;
748
+ // A deadline already in the past (a timer that was cleared by a disconnect, or one
749
+ // that never got armed at all) is honoured on the next tick rather than waiting out
750
+ // another full window.
751
+ const remaining = Math.max(0, this.skeletonDeadline - now);
752
+ this.skeletonWatchdog = setTimeout(() => {
753
+ // Released before anything else can fail, so the handle never dangles.
754
+ this.skeletonWatchdog = null;
755
+ if (!this.isLoading)
31
756
  return;
32
- return this.getProducts();
33
- }).then(() => {
34
- if (!this.offer)
757
+ // Re-armed early, or the clock moved: finish the wait rather than firing short.
758
+ if (Date.now() < this.skeletonDeadline) {
759
+ this.ensureSkeletonIsWatched();
35
760
  return;
36
- salla.event.on("cart::updated", (updatedCart) => this.updateOfferValues(updatedCart));
37
- })
38
- .catch(error => {
39
- this.canRender = false;
40
- salla.logger.warn('salla-conditional-offer:: ', error);
41
- }).finally(() => {
761
+ }
762
+ /**
763
+ * RECOVERY FIRST. Logging used to come first here, which meant a `salla.logger`
764
+ * that throws (see `safeLog`) consumed the timer WITHOUT dropping the skeleton
765
+ * and without printing anything — indistinguishable from "the watchdog never
766
+ * fired", and exactly what was observed live.
767
+ *
768
+ * Note that `safeLog` is SILENT on a production storefront: `salla.logger.warn`
769
+ * opens with `if (!salla.config.isDebug()) return;`. "No console output" is
770
+ * therefore never evidence that this did not run — only the dropped skeleton is.
771
+ */
42
772
  this.isLoading = false;
773
+ this.skeletonDeadline = 0;
774
+ this.safeLog(`still loading after ${SKELETON_TIMEOUT_MS}ms — dropping the skeleton`);
775
+ }, remaining);
776
+ }
777
+ clearSkeletonWatchdog() {
778
+ this.skeletonDeadline = 0;
779
+ if (this.skeletonWatchdog === null)
780
+ return;
781
+ clearTimeout(this.skeletonWatchdog);
782
+ this.skeletonWatchdog = null;
783
+ }
784
+ /**
785
+ * The ONLY way INTO the skeleton. Raising it and arming the watchdog that ends it are
786
+ * deliberately the SAME operation — keeping them apart is what let `refresh()` show a
787
+ * skeleton that nothing was watching. The existing deadline is kept rather than
788
+ * refreshed, so a stream of `cart::updated` events cannot push it out indefinitely.
789
+ */
790
+ startLoading() {
791
+ this.isLoading = true;
792
+ this.ensureSkeletonIsWatched();
793
+ }
794
+ /**
795
+ * The ONLY way out of the skeleton, so the watchdog can never outlive it. Every
796
+ * terminal state — loaded, no-eligible-offer, error — goes through here.
797
+ */
798
+ stopLoading() {
799
+ this.isLoading = false;
800
+ this.clearSkeletonWatchdog();
801
+ }
802
+ /**
803
+ * The async half of the load, deliberately NOT awaited by `componentWillLoad` so it
804
+ * can never gate the first paint. Every state it sets is `@State`, so each step
805
+ * re-renders on its own.
806
+ *
807
+ * NOTHING here is allowed to disable the component permanently.
808
+ */
809
+ async load() {
810
+ /**
811
+ * Subscribed FIRST, and unconditionally. Subscribing only after an offer had been
812
+ * found made every failure terminal: a cold load that saw no offer never
813
+ * subscribed, so the one signal that could revive the component — the shopper
814
+ * changing the cart — was never listened for.
815
+ *
816
+ * WRAPPED, because this runs BEFORE the SDK is known to be ready: `componentWillLoad`
817
+ * fires as soon as the element connects, which on a real storefront can beat
818
+ * `salla.event`'s own bootstrap. A throw here used to propagate out of `load()`,
819
+ * whose promise nobody awaited — so `refresh()` never ran and the skeleton became
820
+ * permanent. Losing the subscription costs live cart updates; losing `refresh()`
821
+ * costs the entire component, so the two failures must not share a fate.
822
+ *
823
+ * ...and "costs live cart updates" is not a price to pay for the whole page view
824
+ * either: a subscription that failed this early gets a second attempt once the SDK
825
+ * says it is ready. See `subscribeWhenSdkIsReady`.
826
+ */
827
+ this.wantsCartUpdates = true;
828
+ if (!this.subscribeToCartUpdates())
829
+ this.subscribeWhenSdkIsReady();
830
+ /**
831
+ * Fired HERE — beside the subscription, not behind the SDK wait and not behind
832
+ * `refresh()` — so the cart lookup races the offers request instead of queueing
833
+ * behind it. An empty cart is answered by the FIRST of the two to land, which is
834
+ * what keeps a shopper on an empty cart from seeing the banner flash before it
835
+ * decides to hide. Unawaited and non-rejecting for the same reason `load()` itself
836
+ * is: nothing about the cart lookup may gate the offer.
837
+ */
838
+ this.loadCart();
839
+ await this.waitForSdk();
840
+ await this.refresh();
841
+ }
842
+ /**
843
+ * `salla.onReady()`, bounded.
844
+ *
845
+ * The previous shape caught a REJECTED bootstrap but could not catch a never-settling
846
+ * one, even though its comment claimed to — and an unbounded await here is the single
847
+ * point at which the whole component can hang with nothing logged. Racing a timer
848
+ * turns "the SDK never signals ready" from fatal into "we ask a few seconds early",
849
+ * which `refresh()` already handles non-terminally and retries on `cart::updated`.
850
+ *
851
+ * Never rejects, and absorbs a synchronous throw from `onReady` itself.
852
+ */
853
+ waitForSdk() {
854
+ let ready;
855
+ try {
856
+ ready = Promise.resolve(salla.onReady?.());
857
+ }
858
+ catch (error) {
859
+ this.safeLog('salla.onReady() threw', error);
860
+ return Promise.resolve();
861
+ }
862
+ // The timer is captured and cleared: `Promise.race` abandons the loser but does
863
+ // NOT cancel it, so a bare `setTimeout` here would leave one dangling timer per
864
+ // mounted component (and, in specs, fire after the test that created it).
865
+ let timer;
866
+ const deadline = new Promise(resolve => {
867
+ timer = setTimeout(resolve, ON_READY_TIMEOUT_MS);
868
+ });
869
+ return Promise.race([
870
+ ready.catch(error => this.safeLog('salla.onReady() rejected', error)),
871
+ deadline,
872
+ ]).then(() => {
873
+ clearTimeout(timer);
43
874
  });
44
875
  }
45
- getProducts() {
46
- const freeProductIDs = this.offer.details.discounts.filter(({ type }) => type === DiscountType.FREE_PRODUCT).map(({ value }) => value).filter(Boolean);
47
- if (freeProductIDs.length > 0) {
48
- return salla.product.fetch({ source: 'selected', source_value: freeProductIDs }).then((response) => {
49
- const data = response?.data;
50
- if (data && Array.isArray(data)) {
51
- const updatedProducts = { ...this.products };
52
- data.forEach(({ id, url, image, name, price }) => {
53
- if (id && name && image?.url && url && price !== undefined) {
54
- updatedProducts[id] = { url, image, name, price };
55
- }
56
- });
57
- this.products = updatedProducts;
58
- }
59
- });
876
+ /**
877
+ * Put `cartUpdatedHandler` on `cart::updated` — at most ONCE, however many paths ask
878
+ * (`load()`, the SDK-ready retry, a reconnect).
879
+ *
880
+ * Never throws, and reports whether the listener is on: `salla.event.on` can throw
881
+ * this early (see `load()`), and the caller decides what a failure is worth.
882
+ */
883
+ subscribeToCartUpdates() {
884
+ if (this.isListeningToCartUpdates)
885
+ return true;
886
+ try {
887
+ salla.event.on("cart::updated", this.cartUpdatedHandler);
888
+ this.isListeningToCartUpdates = true;
889
+ }
890
+ catch (error) {
891
+ this.safeLog('cart::updated subscription failed', error);
60
892
  }
893
+ return this.isListeningToCartUpdates;
61
894
  }
62
- updateInitialOfferValue() {
63
- return salla.api.cart.details().then(({ data: { cart } }) => this.updateOfferValues(cart));
895
+ /** The other half of `subscribeToCartUpdates` — see `disconnectedCallback`. */
896
+ unsubscribeFromCartUpdates() {
897
+ if (!this.isListeningToCartUpdates)
898
+ return;
899
+ // Lowered FIRST: an `off` that throws must not leave a host that believes it is
900
+ // still subscribed, or a later reconnect would never re-register.
901
+ this.isListeningToCartUpdates = false;
902
+ try {
903
+ salla.event.off("cart::updated", this.cartUpdatedHandler);
904
+ }
905
+ catch (error) {
906
+ this.safeLog('cart::updated unsubscription failed', error);
907
+ }
64
908
  }
65
- updateOfferValues({ items, sub_total }) {
66
- const current_value = this.offer.details.based_on === OfferType.PRODUCT_COUNT ? items?.reduce((count, { quantity }) => count + quantity, 0) ?? 0 : sub_total;
67
- //we need to assign the offer object a new reference to trigger re-rendering
68
- this.offer = {
69
- ...this.offer, details: {
70
- ...this.offer?.details, current_value,
71
- }
909
+ /**
910
+ * The second attempt for a subscription that threw before the SDK was up.
911
+ *
912
+ * Hung off `salla.onReady()` ITSELF, not off `waitForSdk()`: that wait gives up after
913
+ * `ON_READY_TIMEOUT_MS` so that it cannot hold the first paint, and an SDK that comes
914
+ * up later than that is exactly the one this is for. Floating for the same reason —
915
+ * nothing in `load()` awaits it, so a bootstrap that never settles costs the listener
916
+ * and nothing else.
917
+ */
918
+ subscribeWhenSdkIsReady() {
919
+ let ready;
920
+ try {
921
+ ready = Promise.resolve(salla.onReady?.());
922
+ }
923
+ catch (error) {
924
+ // `waitForSdk()` reports this same throw; there is no readiness to wait for.
925
+ return;
926
+ }
927
+ const subscribe = () => {
928
+ // A host that went away in the meantime re-subscribes in `connectedCallback`.
929
+ if (!this.isDisconnected)
930
+ this.subscribeToCartUpdates();
72
931
  };
73
- return this.offer;
74
- }
75
- renderTooltip(discount) {
76
- const productId = discount.value;
77
- const productItem = this.products[productId];
78
- const targetId = `avatar-product-${productId}`;
79
- if (!productItem) {
80
- salla.logger.error(`salla-conditional-offer:: there is no product with id (${discount.value})!`);
932
+ // `.then(f, f)`: a bootstrap that REJECTED is still the last word the SDK will have
933
+ // on the matter, and the attempt itself cannot throw.
934
+ ready.then(subscribe, subscribe);
935
+ }
936
+ /**
937
+ * Open (or extend) the coalescing window, so a burst of `cart::updated` events buys ONE
938
+ * refresh pass instead of two server round trips each. See `CART_UPDATE_COALESCE_MS`.
939
+ *
940
+ * Restarting the timer on every event is what makes this trailing-edge: the pass runs
941
+ * `CART_UPDATE_COALESCE_MS` after the LAST event, never after the first, so the burst
942
+ * can never settle on a cart state the shopper has already moved past.
943
+ */
944
+ scheduleCartRefresh() {
945
+ this.cartRefreshPending = true;
946
+ if (this.cartRefreshTimer !== null)
947
+ clearTimeout(this.cartRefreshTimer);
948
+ this.cartRefreshTimer = null;
949
+ // A dead host schedules nothing, but stays OWED the pass — `connectedCallback`
950
+ // re-arms it if the theme puts the host back.
951
+ if (this.isDisconnected)
952
+ return;
953
+ this.cartRefreshTimer = setTimeout(() => {
954
+ this.cartRefreshTimer = null;
955
+ this.runCartRefresh();
956
+ }, CART_UPDATE_COALESCE_MS);
957
+ }
958
+ /**
959
+ * The one pass a burst is entitled to — byte for byte what the `cart::updated` handler
960
+ * used to do inline, minus the count read that now happens per event.
961
+ *
962
+ * The disconnect guard comes FIRST and leaves the flag ALONE, so a host that went away
963
+ * between the last event and the end of its window stays owed the pass rather than
964
+ * silently forfeiting it. Unreachable in practice — `disconnectedCallback` clears the
965
+ * timer — but the two must not disagree about who owes what.
966
+ *
967
+ * Past that, the flag is cleared BEFORE the work, not after: an event that arrives
968
+ * while this pass is in flight must be able to open a NEW window (and it does —
969
+ * `scheduleCartRefresh` sets the flag again), and clearing afterwards would swallow it.
970
+ */
971
+ runCartRefresh() {
972
+ if (this.isDisconnected)
973
+ return;
974
+ this.cartRefreshPending = false;
975
+ this.refresh();
976
+ refreshCartFreeGifts().then(() => this.loadCart(), () => this.loadCart());
977
+ }
978
+ /**
979
+ * Take the cart's item count straight off the `cart::updated` payload.
980
+ *
981
+ * THIS IS THE FIX FOR THE LIVE REMOVAL PATH, and it exists because the request-based
982
+ * count cannot answer there. When the shopper deletes their last line the cart id goes
983
+ * away, and BOTH cart endpoints then reject rather than reporting an empty cart:
984
+ * `details()` resolves its id through `getCurrentCartId(false, …, allowCreate=false)`,
985
+ * which returns `null` for a guest with no stored cart id, and throws `CartNotCreated`
986
+ * (twilight/src/api/cart.js:192-199); `offers()` throws through the same door
987
+ * (cart.js:869-874). `refreshCartFreeGifts()` has meanwhile invalidated the shared
988
+ * cache, so the count resolves to `null` — "unknown" — and an unknown count renders the
989
+ * banner. That is the beta regression: a cart that is empty, reported as unknowable.
990
+ *
991
+ * The payload has none of that dependency. It is delivered synchronously, by the same
992
+ * event that told us the cart changed, and it cannot fail, hang, or lose a race.
993
+ *
994
+ * Two sources, in order: the payload's own `count`, then the `cart.summary.count` the
995
+ * same emitter wrote a line earlier. Both are strict — see `toCartCount` — so a payload
996
+ * that states nothing leaves the previous value untouched rather than reading as empty.
997
+ */
998
+ readCartCountFromEvent(payload) {
999
+ const count = this.toCartCount(payload?.count) ?? this.readCartCountFromStorage();
1000
+ if (count === null)
1001
+ return;
1002
+ this.cartItemCount = count;
1003
+ }
1004
+ /**
1005
+ * The SECONDARY count, for a `cart::updated` that carried no usable `count` of its own.
1006
+ *
1007
+ * `SallaCartEvent.updated()` writes `cart.summary.count` from `data.cart`
1008
+ * (twilight/src/event/cart.js:102-110) SYNCHRONOUSLY, immediately before it dispatches
1009
+ * the event — so inside a `cart::updated` handler this value is as fresh as the payload
1010
+ * and comes from the same object. `mobileBridge._cart` falls back to exactly this, for
1011
+ * exactly this reason.
1012
+ *
1013
+ * ONLY safe from inside that handler, which is why it is private to
1014
+ * `readCartCountFromEvent` and never consulted at mount: on a cold load the same key
1015
+ * can hold a count left over from a previous session, and trusting it there would hide
1016
+ * the banner on a cart that is not empty.
1017
+ *
1018
+ * Read through the public `salla.storage` facade; wrapped, because storage is not
1019
+ * guaranteed to be installed and a throw here must cost nothing.
1020
+ */
1021
+ readCartCountFromStorage() {
1022
+ try {
1023
+ return this.toCartCount(salla.storage?.get?.('cart.summary.count'));
1024
+ }
1025
+ catch (error) {
81
1026
  return null;
82
1027
  }
83
- 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) }))));
84
1028
  }
85
- getCheckpointContent(discount) {
86
- if (discount.type === DiscountType.PERCENTAGE)
87
- return `${discount.value}%`;
88
- if (discount.type === DiscountType.FIXED)
89
- return h("span", { innerHTML: salla.money(discount.value) });
90
- if (discount.type !== DiscountType.FREE_PRODUCT) {
91
- salla.logger.error(`salla-conditional-offer:: unexpected type (${discount.type})!`);
92
- return "";
1029
+ /**
1030
+ * Coerce a reported count, STRICTLY. Only a finite, non-negative number is an answer;
1031
+ * `undefined`, `null`, a string and a negative all mean "not stated", which leaves the
1032
+ * previous value alone — the degenerate `{ fromLatestFetched }` payload
1033
+ * (event/cart.js:85) must never read as an empty cart.
1034
+ */
1035
+ toCartCount(value) {
1036
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
1037
+ }
1038
+ getFreeLabel() {
1039
+ const text = salla.lang.get(FREE_LABEL_KEY);
1040
+ return text && text !== FREE_LABEL_KEY ? text : FREE_LABEL_FALLBACK;
1041
+ }
1042
+ /**
1043
+ * Read a `blocks.cart_offer.*` translation.
1044
+ *
1045
+ * `salla.lang.get` returns the KEY VERBATIM when a translation is missing, which would
1046
+ * put "blocks.cart_offer.congrats" in front of a shopper. A missing key reads as its
1047
+ * `EN_FALLBACKS` entry instead, with the same `:placeholder` substitution.
1048
+ */
1049
+ trans(key, replacements) {
1050
+ const fullKey = `${LANG}.${key}`;
1051
+ const text = salla.lang.get(fullKey, replacements);
1052
+ if (text && text !== fullKey)
1053
+ return text;
1054
+ return Object.entries(replacements ?? {}).reduce((out, [token, value]) => out.split(`:${token}`).join(value), EN_FALLBACKS[key]);
1055
+ }
1056
+ /**
1057
+ * The ONLY eligibility question worth asking: does this store have the feature?
1058
+ *
1059
+ * `salla.config.get` is a plain object read and cannot throw, but the whole thing is
1060
+ * still wrapped: this predicate is called from a lifecycle hook, and "the SDK is not
1061
+ * on the page yet" must degrade to "not eligible *yet*", never to an exception.
1062
+ */
1063
+ isEnabled() {
1064
+ try {
1065
+ return !!salla.config.get('store.features')?.includes('conditional-offer');
93
1066
  }
94
- const productItem = this.products[discount.value];
95
- if (!productItem) {
96
- salla.logger.error(`salla-conditional-offer:: there is no product with id (${discount.value})!`);
97
- return "";
1067
+ catch (error) {
1068
+ return false;
98
1069
  }
99
- 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 }));
100
1070
  }
101
- getOfferType(discount) {
102
- const basedOn = this.offer.details?.based_on;
1071
+ /**
1072
+ * ONE banner, even when several cart offers are live the FIRST conditional
1073
+ * offer carrying at least one checkpoint wins and the rest are ignored. Merging them
1074
+ * would produce a badge row whose tiers belong to different promotions.
1075
+ */
1076
+ async fetchOffer() {
1077
+ const { data } = await salla.api.cart.offers();
1078
+ const offer = (data || []).find(offer => offer.type === "conditional");
1079
+ return offer?.details?.discounts?.length ? offer : null;
1080
+ }
1081
+ /**
1082
+ * The single entry point for "ask the server what this cart is entitled to".
1083
+ *
1084
+ * Runs on the initial load AND on every `cart::updated`, and is IDEMPOTENT and
1085
+ * REVERSIBLE in both directions — `canRender` is derived from this pass alone, so a
1086
+ * cart that becomes eligible after starting out ineligible turns the component back
1087
+ * on. No state set here is a one-way door.
1088
+ */
1089
+ async refresh() {
1090
+ const generation = ++this.refreshGeneration;
1091
+ // Re-asked on EVERY pass rather than latched at mount. `store.features` arrives
1092
+ // with the page bootstrap, but a mount that beats it must be able to recover.
1093
+ if (!this.isEnabled()) {
1094
+ this.stopLoading();
1095
+ this.canRender = false;
1096
+ return;
1097
+ }
1098
+ /**
1099
+ * A cart we already KNOW is empty is a terminal answer: stop, render nothing, and
1100
+ * never enter the loading state — the cheapest correct behaviour, and the one that
1101
+ * makes a skeleton over "السلة فارغة" structurally impossible rather than merely
1102
+ * short-lived. Without this, emptying the cart still raised a skeleton whenever the
1103
+ * offer had been cleared, and a hanging `offers()` then held it up for the whole
1104
+ * watchdog window.
1105
+ *
1106
+ * `=== 0` only: `null` is "not answered yet" and must still take the normal path,
1107
+ * and the offer itself is deliberately left untouched, so re-adding an item brings
1108
+ * the banner straight back without another round trip.
1109
+ */
1110
+ if (this.cartItemCount === 0) {
1111
+ this.stopLoading();
1112
+ /**
1113
+ * ...but not a DEAD END when the `0` was never corroborated. This is the only
1114
+ * branch that returns without ever asking for the offer, so a `0` that was
1115
+ * wrong makes the banner unreachable for the rest of the page view: nothing
1116
+ * else calls `refresh()` except `cart::updated`, which a shopper who does not
1117
+ * touch the cart never emits.
1118
+ *
1119
+ * And a wrong `0` is reachable. The first cart lookup is fired from `load()`
1120
+ * BEFORE `salla.onReady()` settles, so it can be answered by an SDK that has
1121
+ * not installed the cart id yet — `details()` then rejects `CartNotCreated`,
1122
+ * which the shared module reads (rightly, for a real guest) as an empty cart.
1123
+ * Re-asking is what tells the two apart, and on a cart that IS empty it costs
1124
+ * nothing: the retry rejects again without reaching the network.
1125
+ *
1126
+ * GATED ON `hasAuthoritativeCartData()`, which is the whole safety of it. A
1127
+ * `0` that came from a response we actually read is FINAL and must stay final
1128
+ * — re-asking there would answer from the pre-mutation cache and bring the
1129
+ * banner back over an emptied cart, which is the beta bug this branch exists
1130
+ * to prevent. Only a `0` the lookup GUESSED is retried.
1131
+ */
1132
+ if (!hasAuthoritativeCartData()) {
1133
+ this.deferredByEmptyCart = true;
1134
+ this.loadCart();
1135
+ }
1136
+ return;
1137
+ }
1138
+ // Past the empty-cart branch, so this pass IS going to ask for the offer and there
1139
+ // is nothing left for `loadCart()` to recover.
1140
+ this.deferredByEmptyCart = false;
1141
+ // Skeleton only while there is nothing to show yet. A refresh behind an already
1142
+ // rendered offer updates in place instead of collapsing to a skeleton. Going
1143
+ // through `startLoading()` is what guarantees a watchdog exists behind a skeleton
1144
+ // this method raised — `componentWillLoad` may never have armed one, because
1145
+ // `isEnabled()` can legitimately be false at mount and true by the time we get here.
1146
+ if (this.offer)
1147
+ this.stopLoading();
1148
+ else
1149
+ this.startLoading();
1150
+ try {
1151
+ const offer = await this.fetchOffer();
1152
+ // A newer cart::updated issued its own request while this one was in flight.
1153
+ // That request reflects a later cart state, so this response is stale by
1154
+ // definition — drop it rather than re-issuing.
1155
+ if (this.isSuperseded(generation))
1156
+ return;
1157
+ if (!offer) {
1158
+ this.offer = null;
1159
+ this.canRender = false;
1160
+ return;
1161
+ }
1162
+ // A new object reference is what triggers the re-render.
1163
+ this.offer = offer;
1164
+ this.canRender = true;
1165
+ // Gift LINES are an enhancement fetched only AFTER the banner has been handed
1166
+ // to `render()`; `gifts` is `@State`, so the card appears by itself when the
1167
+ // lookup lands, and a lookup that never settles costs nothing but the card.
1168
+ // Re-asked now that `this.offer` is known, because the gift filter is keyed to
1169
+ // the offer id — the early pass in `load()` could only answer the COUNT.
1170
+ this.loadCart();
1171
+ }
1172
+ catch (error) {
1173
+ if (this.isSuperseded(generation))
1174
+ return;
1175
+ /**
1176
+ * NOT terminal, and deliberately does not touch `canRender`. A failing
1177
+ * `offers()` call is a transient network/SDK condition, not a statement that
1178
+ * the promotion does not exist.
1179
+ */
1180
+ this.safeLog('offers() failed', error);
1181
+ }
1182
+ finally {
1183
+ /**
1184
+ * The skeleton ends here, on EVERY exit path — including the two early
1185
+ * `return`s above, which `finally` still runs.
1186
+ *
1187
+ * Lowering `isLoading` used to live inside the try, AFTER the staleness guard,
1188
+ * which conflated two different questions: "whose DATA wins" (the generation) and
1189
+ * "are we still loading" (this). A superseded or throwing pass answered the
1190
+ * first and silently skipped the second, leaving the skeleton up as a resting
1191
+ * state. Only the NEWEST pass lowers it, so an early-settling stale response
1192
+ * cannot flash empty content while the authoritative request is still in
1193
+ * flight — and because `finally` is unconditional, that newest pass always
1194
+ * reaches this line.
1195
+ */
1196
+ if (generation === this.refreshGeneration)
1197
+ this.stopLoading();
1198
+ }
1199
+ }
1200
+ /**
1201
+ * The single gate on rendering anything at all: an AFFIRMATIVE "the cart holds at
1202
+ * least one item".
1203
+ *
1204
+ * Deliberately not `!== 0` and not a truthy test. `cartItemCount` is `null` until
1205
+ * something answers, and `null` must read as "do not render" — the default is hidden,
1206
+ * and only a real, non-negative count of 1 or more opens the gate.
1207
+ */
1208
+ hasCartItems() {
1209
+ return typeof this.cartItemCount === 'number' && this.cartItemCount >= 1;
1210
+ }
1211
+ /**
1212
+ * Whether this pass's response should be discarded: a newer `refresh()` has since
1213
+ * been issued (so this one describes an older cart), or the host is gone. Answers
1214
+ * "whose data wins" ONLY — never "should the skeleton stay up"; see the `finally`
1215
+ * in `refresh()`.
1216
+ */
1217
+ isSuperseded(generation) {
1218
+ return generation !== this.refreshGeneration || this.isDisconnected;
1219
+ }
1220
+ /**
1221
+ * The two things this component needs from the CART itself: how many lines it holds,
1222
+ * and which of them are this offer's free gifts.
1223
+ *
1224
+ * ONE lookup for both. `getCartItemCount` and `getCartFreeGiftItems` are two
1225
+ * projections of the same shared `details()` response and share its cache, its
1226
+ * in-flight promise and its generation guard, so asking for both costs exactly one
1227
+ * request — and, more importantly, the count and the gift lines can never describe
1228
+ * two different cart states.
1229
+ *
1230
+ * Never rejects: a failing lookup is a missing "ادارة العرض" card and an UNKNOWN
1231
+ * count, not a missing promotion and not an empty cart.
1232
+ */
1233
+ loadCart() {
1234
+ const generation = ++this.giftsGeneration;
1235
+ return Promise.all([getCartFreeGiftItems(), getCartItemCount()])
1236
+ .then(([items, count]) => {
1237
+ if (generation !== this.giftsGeneration || this.isDisconnected)
1238
+ return;
1239
+ /**
1240
+ * A `cart::updated` is waiting for its pass, so this answer describes the
1241
+ * cart BEFORE that mutation whichever way it was obtained: in flight when
1242
+ * the event arrived, or started since and served the pre-mutation count
1243
+ * without a request, because the shared cache is only invalidated when the
1244
+ * window closes. Publishing it would put a stale positive count back over
1245
+ * the event's `0`. Dropped outright: the pass re-asks, against an
1246
+ * invalidated cache.
1247
+ */
1248
+ if (this.cartRefreshPending)
1249
+ return;
1250
+ /**
1251
+ * NEVER walks a KNOWN count back to `null`. `count` is `null` whenever the
1252
+ * shared lookup could not answer — which, on the removal path, is every
1253
+ * time (see `readCartCountFromEvent`). Publishing that `null` over the 0 the
1254
+ * event already established is exactly how the banner came back from the
1255
+ * dead; an unknown answer is not new information, so it changes nothing.
1256
+ */
1257
+ if (count !== null)
1258
+ this.cartItemCount = count;
1259
+ /**
1260
+ * An answer ends the retry episode. No answer, on a cart whose count is
1261
+ * STILL unknown, buys another attempt — see `CART_LOOKUP_RETRY_DELAYS_MS`.
1262
+ * A count something else already established needs no retry: the banner's
1263
+ * gate is open or shut on it either way.
1264
+ */
1265
+ if (count !== null)
1266
+ this.clearCartLookupRetry();
1267
+ else if (this.cartItemCount === null)
1268
+ this.scheduleCartLookupRetry();
1269
+ /**
1270
+ * The cart turned out to hold items after all — so the `refresh()` that
1271
+ * bailed out before asking for the offer did so on a count that was wrong,
1272
+ * and nothing else will ever re-run it. Run it here.
1273
+ *
1274
+ * The latch is what keeps this to a RECOVERY rather than a second refresh
1275
+ * on every load: it is set by exactly one branch of `refresh()` and
1276
+ * cleared both here and by the first pass that gets past that branch, so
1277
+ * the normal path — and a store with no conditional offer at all — never
1278
+ * reaches this line. Bounded by construction: `refresh()` re-asks the cart
1279
+ * only on `count === 0`, this re-runs `refresh()` only on `count >= 1`,
1280
+ * and it can only do so while the latch is set.
1281
+ */
1282
+ if (this.deferredByEmptyCart && this.hasCartItems()) {
1283
+ this.deferredByEmptyCart = false;
1284
+ this.refresh();
1285
+ }
1286
+ /**
1287
+ * The gift card is keyed to the offer, so it can only be filled once the
1288
+ * offer is known. The early pass fired from `load()` runs BEFORE
1289
+ * `refresh()` has settled; publishing gifts from it would let another
1290
+ * promotion's gift line flash in the card, because `selectOwnGifts` has no
1291
+ * offer id to filter on yet. `refresh()` calls back here the moment it has
1292
+ * one.
1293
+ */
1294
+ this.gifts = this.offer ? this.selectOwnGifts(items) : [];
1295
+ })
1296
+ .catch(error => {
1297
+ this.safeLog('cart lookup failed', error);
1298
+ });
1299
+ }
1300
+ /**
1301
+ * Arm the next cart-lookup retry, unless one is already armed or the episode has spent
1302
+ * its attempts. See `CART_LOOKUP_RETRY_DELAYS_MS`.
1303
+ *
1304
+ * The attempt is SPENT when the timer fires, not when it is armed, so a timer a
1305
+ * disconnect cleared is re-armed by `connectedCallback` without costing one.
1306
+ */
1307
+ scheduleCartLookupRetry() {
1308
+ if (this.cartLookupRetries >= CART_LOOKUP_RETRY_DELAYS_MS.length)
1309
+ return;
1310
+ this.cartLookupRetryPending = true;
1311
+ // A dead host arms nothing, but stays owed the retry — as `scheduleCartRefresh`.
1312
+ if (this.cartLookupRetryTimer !== null || this.isDisconnected)
1313
+ return;
1314
+ this.cartLookupRetryTimer = setTimeout(() => {
1315
+ this.cartLookupRetryTimer = null;
1316
+ this.cartLookupRetryPending = false;
1317
+ // Decided HERE rather than when the timer was armed, which can be before
1318
+ // `refresh()` has answered: with the feature off, or no offer on the store,
1319
+ // there is nothing the count could reveal and so nothing worth a request.
1320
+ if (!this.canRender)
1321
+ return;
1322
+ this.cartLookupRetries++;
1323
+ this.loadCart();
1324
+ }, CART_LOOKUP_RETRY_DELAYS_MS[this.cartLookupRetries]);
1325
+ }
1326
+ /**
1327
+ * End the retry episode: a lookup answered, or the cart changed under it. The count
1328
+ * goes back to zero so a LATER failure gets its own full set of attempts.
1329
+ */
1330
+ clearCartLookupRetry() {
1331
+ this.cartLookupRetries = 0;
1332
+ this.cartLookupRetryPending = false;
1333
+ if (this.cartLookupRetryTimer === null)
1334
+ return;
1335
+ clearTimeout(this.cartLookupRetryTimer);
1336
+ this.cartLookupRetryTimer = null;
1337
+ }
1338
+ /**
1339
+ * Keep only the gift lines this offer granted.
1340
+ *
1341
+ * `CartItemTransformer::getFreeGiftData` emits `offer_id` OPTIMUS-ENCODED, exactly
1342
+ * like `CartOffersTransformer` emits `id`, so the two join directly. When the offer id
1343
+ * is unreadable we fall back to every gift line rather than hiding the card: only one
1344
+ * cart offer can be applied to a cart at a time, so an unmatched gift is far more
1345
+ * likely to be an id-shape surprise than another promotion's product.
1346
+ */
1347
+ selectOwnGifts(items) {
1348
+ const offerId = Number(this.offer?.id);
1349
+ if (!Number.isFinite(offerId))
1350
+ return items;
1351
+ const owned = items.filter(gift => gift.offerId !== null && Number(gift.offerId) === offerId);
1352
+ return owned.length ? owned : items.filter(gift => gift.offerId === null);
1353
+ }
1354
+ /**
1355
+ * Stages in ascending threshold order — the payload itself is UNSORTED.
1356
+ *
1357
+ * LOAD-BEARING: this filter and sort must mirror what `computeOfferStageProgress`
1358
+ * does internally, because every renderer pairs `stages[i]` with
1359
+ * `progress.thresholds[i]`. Both drop unusable thresholds via `isUsableThreshold` and
1360
+ * both sort ascending and stably, so the two arrays stay index-aligned.
1361
+ */
1362
+ getStages() {
1363
+ const usable = (this.offer?.details?.discounts ?? []).filter(({ min_spend }) => isUsableThreshold(min_spend));
1364
+ // Ties break on the tier `id` (0 = base tier, 1..N = options in engine order), so two
1365
+ // tiers sharing a `min_spend` order by identity rather than by their position in an
1366
+ // unsorted payload. `id` is absent on an older backend; then it is the old index tie-break.
1367
+ return sortByThreshold(usable, ({ min_spend }) => min_spend, ({ id }) => id);
1368
+ }
1369
+ /**
1370
+ * The tier picture: the ENGINE'S verdict per tier plus the threshold projection.
1371
+ *
1372
+ * `is_applied` is passed positionally from THIS array, so the flag can never be
1373
+ * attributed to the wrong tier — `stages[i]`, `progress.thresholds[i]`,
1374
+ * `progress.met[i]` and `progress.applied[i]` are one row. Alignment matters more
1375
+ * than ever now: the winner's INDEX decides where the ✓ run stops.
1376
+ *
1377
+ * Each tier's OWN `current_value` rides along the same way. It is what the distance in
1378
+ * the guidance line is measured against, and a tier that carries none — every tier, on
1379
+ * an older backend — is measured by the offer-level value exactly as before.
1380
+ */
1381
+ getProgress(stages) {
1382
+ 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));
1383
+ }
1384
+ /**
1385
+ * A tier's render key. `id` is the stable identity across refetches; the array
1386
+ * position is the last resort for a backend that does not send one.
1387
+ */
1388
+ tierKey(discount, index) {
1389
+ const id = Number(discount?.id);
1390
+ return Number.isFinite(id) ? `tier-${id}` : `pos-${index}`;
1391
+ }
1392
+ /**
1393
+ * How this offer MEASURES its thresholds, as `offer-stages` names it .
1394
+ *
1395
+ * The remainder is rounded to the measure — whole products for `products_count`,
1396
+ * store currency precision for everything else — so `formatRequirement` is handed a
1397
+ * value its formatter can actually render. A `products_count` offer is the one that
1398
+ * MUST be told apart: `salla.lang.choice` matches a fraction against no plural
1399
+ * interval and answers "no products".
1400
+ */
1401
+ stageMeasure() {
1402
+ return this.offer?.details?.based_on === OfferType.PRODUCT_COUNT
1403
+ ? OfferStageMeasure.COUNT
1404
+ : OfferStageMeasure.CURRENCY;
1405
+ }
1406
+ /**
1407
+ * Format a cart value (a threshold, or a remaining amount/quantity) the way the
1408
+ * offer is measured: money for `order_amount`, a pluralised item count for
1409
+ * `products_count`. May return HTML (`salla.money`), so callers use `innerHTML`.
1410
+ */
1411
+ formatRequirement(value) {
1412
+ const basedOn = this.offer?.details?.based_on;
103
1413
  if (basedOn === OfferType.PRODUCT_COUNT) {
104
1414
  try {
105
- return salla.lang.choice("blocks.header.products_count", discount.min_spend);
1415
+ return salla.lang.choice("blocks.header.products_count", value);
106
1416
  }
107
1417
  catch (error) {
108
- // Some stores may define an invalid interval (e.g. [1,*]) in translations.
109
- // Fallback to a safe plain label so the component does not crash.
110
- salla.logger.warn("salla-conditional-offer:: invalid products_count interval", error);
111
- return `${discount.min_spend}`;
1418
+ // Some stores define an invalid interval (e.g. [1,*]) in translations.
1419
+ // safeLog, not salla.logger: this runs inside render(), where a throwing
1420
+ // logger would take the whole component down.
1421
+ this.safeLog('invalid products_count interval', error);
1422
+ return `${value}`;
112
1423
  }
113
1424
  }
114
1425
  if (basedOn === OfferType.PRODUCT_PURCHASE)
115
- return salla.money(discount.min_spend);
116
- salla.logger.warn(`salla-conditional-offer:: Unexpected offer detail's based_on value: ${basedOn}`);
117
- return `${discount.min_spend}`;
118
- }
119
- clamp(value, min, max) {
120
- return Math.max(min, Math.min(value, max));
121
- }
122
- mapValueRanges(value, initialMinRange, initialMaxRange, newMinRange, newMaxRange) {
123
- const newRange = ((value - initialMinRange) * (newMaxRange - newMinRange)) / (initialMaxRange - initialMinRange) + newMinRange;
124
- if (newRange === Number.POSITIVE_INFINITY)
125
- return 100;
126
- if (newRange === Number.NEGATIVE_INFINITY)
127
- return 0;
128
- return this.clamp(newRange, 0, 100);
129
- }
130
- getCheckPointView(discount, index) {
131
- const checkpointIndex = this.offer.details.discounts.findIndex(({ min_spend }) => min_spend === discount.min_spend);
132
- const previousCheckpointValue = this.offer.details.discounts[checkpointIndex - 1]?.min_spend ?? 0;
133
- const progressPercentage = this.mapValueRanges(this.offer.details.current_value, previousCheckpointValue, discount.min_spend, 0, 100);
134
- const isActive = discount.min_spend <= this.offer.details.current_value;
135
- return (h("div", { class: "s-conditional-offer-checkpoint-container" }, index > 0 ? [
136
- 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}%` } })),
137
- 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) }))
138
- ] :
139
- h("div", { key: "label", class: { "s-conditional-offer-checkpoint-label": true, "first-checkpoint": index === 0, active: isActive }, innerHTML: this.getOfferType(discount) })));
1426
+ return salla.money(value);
1427
+ this.safeLog(`unexpected offer detail's based_on value: ${basedOn}`);
1428
+ return `${value}`;
1429
+ }
1430
+ /**
1431
+ * The shopper-facing reward text, SERVER-RENDERED.
1432
+ *
1433
+ * Never re-derived here. `reward_label` already carries the type-specific wording,
1434
+ * the currency the value was converted to, and the merchant's own theme-translation
1435
+ * overrides. Empty (free_shipping, unknown types) means "render nothing", which is
1436
+ * why every caller has a reward-less sentence to fall back to.
1437
+ */
1438
+ getRewardText(discount) {
1439
+ return (discount?.reward_label ?? '').trim();
1440
+ }
1441
+ /**
1442
+ * Guidance toward the first tier ABOVE the one the engine granted. Empty once
1443
+ * that is the last tier (then the congratulations line renders instead).
1444
+ *
1445
+ * The TARGET comes from the verdict (`nextUnmetIndex`), the DISTANCE from the
1446
+ * arithmetic — which is legitimate here, because "add :remaining" is a projection
1447
+ * about the shopper's cart, not a claim about what the engine did. It is measured in
1448
+ * the TARGET tier's own `current_value` when the backend sends one (see `getProgress`).
1449
+ *
1450
+ * Without that the two can disagree: the engine can withhold a tier whose threshold is
1451
+ * already met, because its own excluded-product set is not in the payload. Then the distance
1452
+ * is 0 and "Add SAR 0 and get a free gift" is false in both halves, so the line is
1453
+ * dropped rather than rendered — and we do NOT skip ahead to a farther tier, which
1454
+ * would advertise a reward past a nearer one the shopper cannot have. Nothing is the
1455
+ * honest answer; the tooltip still shows the tier as locked.
1456
+ */
1457
+ getGuidanceMessage(stages, progress) {
1458
+ if (progress.nextUnmetIndex === NO_STAGE_REACHED)
1459
+ return '';
1460
+ const remainingValue = getRemainingToStage(progress.nextUnmetIndex, progress, this.stageMeasure());
1461
+ if (remainingValue <= 0)
1462
+ return this.trans('guidance_conditions_unmet');
1463
+ const remaining = this.formatRequirement(remainingValue);
1464
+ const reward = this.getRewardText(stages[progress.nextUnmetIndex]);
1465
+ return reward
1466
+ ? this.trans('guidance_next', { remaining, reward: escapeHtml(reward) })
1467
+ : this.trans('guidance_next_generic', { remaining });
1468
+ }
1469
+ /** One tooltip row's sentence: "spend X, get Y", both halves from the offer payload. */
1470
+ getTierSentence(discount, index, progress) {
1471
+ const threshold = this.formatRequirement(progress.thresholds[index]);
1472
+ const reward = this.getRewardText(discount);
1473
+ /**
1474
+ * "Spend" is a MONEY verb, and the Arabic frame says it outright ("اشترِ بقيمة")
1475
+ * — so a products_count offer read "buy 3 products WORTH", describing a cart value
1476
+ * that no tier in it measures. The threshold half was already measure-correct
1477
+ * (@see formatRequirement); only the sentence around it assumed currency.
1478
+ */
1479
+ const isCount = this.stageMeasure() === OfferStageMeasure.COUNT;
1480
+ return reward
1481
+ ? this.trans(isCount ? 'tier_requirement_count' : 'tier_requirement', { threshold, reward: escapeHtml(reward) })
1482
+ : this.trans(isCount ? 'tier_requirement_count_generic' : 'tier_requirement_generic', { threshold });
1483
+ }
1484
+ /**
1485
+ * The reward-TYPE icon shown in a tooltip row's circle.
1486
+ *
1487
+ * `fixed` is the awkward one: the design draws the Saudi Riyal glyph, but a store
1488
+ * selling in USD must not. `sicon-sar` is used on exactly the same condition
1489
+ * `salla.money()` uses to swap the currency for that glyph
1490
+ * (`base/src/helpers/numbers.ts:78-86`), and a currency-neutral banknote otherwise.
1491
+ */
1492
+ getRewardIcon(discount) {
1493
+ switch (discount?.type) {
1494
+ case DiscountType.PERCENTAGE:
1495
+ return 'sicon-percentage';
1496
+ case DiscountType.FREE_PRODUCT:
1497
+ return 'sicon-gift';
1498
+ case DiscountType.FIXED:
1499
+ return this.usesSarSymbol() ? 'sicon-sar' : 'sicon-banknote';
1500
+ default:
1501
+ return 'sicon-tag';
1502
+ }
1503
+ }
1504
+ usesSarSymbol() {
1505
+ try {
1506
+ const symbol = salla.config.currency?.()?.symbol;
1507
+ return ['SAR', 'ر.س'].includes(symbol) && !!salla.config.get('store.settings.use_sar_symbol');
1508
+ }
1509
+ catch (error) {
1510
+ return false;
1511
+ }
1512
+ }
1513
+ // ---------------------------------------------------------------------------------
1514
+ // Tooltip — opens on hover AND on click (the click is the mobile path), and
1515
+ // is dismissible by Escape, by an outside click, and by un-hovering.
1516
+ // ---------------------------------------------------------------------------------
1517
+ get isTooltipOpen() {
1518
+ return this.isTooltipHovered || this.isTooltipPinned;
1519
+ }
1520
+ toggleTooltip() {
1521
+ this.isTooltipPinned = !this.isTooltipPinned;
1522
+ }
1523
+ closeTooltip(restoreFocus = false) {
1524
+ if (!this.isTooltipOpen)
1525
+ return;
1526
+ this.isTooltipPinned = false;
1527
+ this.isTooltipHovered = false;
1528
+ if (restoreFocus)
1529
+ this.infoTriggerEl?.focus?.();
1530
+ }
1531
+ /**
1532
+ * WHY THIS IS NOT `salla-tooltip`.
1533
+ *
1534
+ * It is a fair question — the component exists, it is shared, and this component used
1535
+ * to depend on it (master renders a `<salla-tooltip theme="dark">` product preview off
1536
+ * the badge avatars). That usage was hover-only, non-interactive and string-sized, and
1537
+ * it is exactly the shape `salla-tooltip` is built for. The ⓘ popover this design asks
1538
+ * for is not, and the gap is not one prop wide:
1539
+ *
1540
+ * - VISIBILITY IS PRIVATE TO IT. `show` is `@State`, flipped only by the component's
1541
+ * own `mouseover`/`mouseout` window listeners (salla-tooltip.tsx:26-38). There is no
1542
+ * `open` prop and no way for an owner to pin it, so the CLICK path — which is the
1543
+ * ONLY way this popover opens on a touch device — cannot be expressed at all.
1544
+ * - IT IS DELIBERATELY UNREACHABLE. `pointer-events: none` (salla-tooltip.scss:20).
1545
+ * A pinned panel the shopper cannot hover or scroll is not a mobile path.
1546
+ * - NO DISMISSAL MODEL. Escape and outside-click do not exist there, because with
1547
+ * hover-only visibility there is nothing to dismiss.
1548
+ * - NO POSITIONING TO REUSE. It is pinned by two hand-tuned magic percentages —
1549
+ * `translateX(-63%)` and `translateX(-72%)` under `:dir(ltr)` — with a caret nailed
1550
+ * to `left: 50%` and never re-aimed. There is no viewport clamp, so the exact
1551
+ * behaviour this method exists for is the one piece it does not have.
1552
+ * - IT IS SIZED FOR A STRING. `max-width: 150px` / `width: 190px` default,
1553
+ * `max-width: 330px` dark. This panel is a title, a description and one row per
1554
+ * tier.
1555
+ * - IT ANCHORS BY GLOBAL ID. `document.getElementById(targetId)` at `componentDidLoad`
1556
+ * (salla-tooltip.tsx:40-43). The ⓘ trigger has no stable document-unique id, and the
1557
+ * banner can legitimately mount more than once on a page.
1558
+ *
1559
+ * Closing all six is not "a prop, not a hack" — it is a controlled-visibility API, a
1560
+ * dismissal model, an anchoring change and a positioning engine, i.e. a rewrite of all
1561
+ * 55 lines of it. And `salla-tooltip` is SHARED: `salla-comment-item` and `salla-wallet`
1562
+ * both pass a plain `text` + `targetId` and depend on precisely the hover-only,
1563
+ * `pointer-events: none`, magic-percentage behaviour above, with a single spec between
1564
+ * them that asserts its exact rendered HTML. Rewriting it under them to serve this
1565
+ * banner trades a scoped 90 lines here for a silent regression surface there.
1566
+ *
1567
+ * So the bespoke implementation stays, and stays SMALL: everything below is the clamp
1568
+ * and the caret. The resting position is plain CSS.
1569
+ *
1570
+ * Keep the popover pointing at the ⓘ icon AND inside the viewport.
1571
+ *
1572
+ * The CSS already centres it on the trigger — that is the resting answer, and it is
1573
+ * correct in RTL and LTR alike because centring is symmetric, which is exactly why the
1574
+ * rules are written with physical `left`/`translateX` rather than `inset-inline-*`.
1575
+ * Anything logical would have to be mirrored again here, in a coordinate space
1576
+ * (`getBoundingClientRect`) that is physical no matter what the document direction is.
1577
+ *
1578
+ * All this adds on top is the CLAMP: at narrow widths a popover centred on an icon
1579
+ * that sits near the edge of the screen would hang off it, so the box is nudged back
1580
+ * inside and the caret is shifted by the same amount in the opposite direction, so it
1581
+ * stays over the icon while the box moves out from under it.
1582
+ *
1583
+ * Written as CSS CUSTOM PROPERTIES on the element, never as `@State`: this runs from
1584
+ * `componentDidRender`, and anything that re-entered render() from here would loop.
1585
+ */
1586
+ positionTooltip() {
1587
+ const tooltip = this.tooltipEl;
1588
+ const trigger = this.infoTriggerEl;
1589
+ if (!this.isTooltipOpen || !tooltip || !trigger)
1590
+ return;
1591
+ try {
1592
+ /**
1593
+ * Zeroed BEFORE measuring. The rect below already includes whatever shift the
1594
+ * previous pass wrote, so correcting incrementally would drift a little further
1595
+ * every render; from zero the measurement is absolute and one pass is exact.
1596
+ */
1597
+ this.setTooltipOffsets(tooltip, 0, 0);
1598
+ const viewport = window.innerWidth || document.documentElement?.clientWidth || 0;
1599
+ const box = tooltip.getBoundingClientRect();
1600
+ const anchor = trigger.getBoundingClientRect();
1601
+ const width = box?.width || 0;
1602
+ /**
1603
+ * No layout to measure — a spec DOM, a display:none ancestor, a browser that
1604
+ * has not painted yet. The CSS default (centred on the trigger) is already the
1605
+ * right answer everywhere except at a viewport edge, so degrade to it rather
1606
+ * than to NaN.
1607
+ */
1608
+ if (width <= 0 || viewport <= 0)
1609
+ return;
1610
+ const centre = anchor.left + anchor.width / 2;
1611
+ const ideal = centre - width / 2;
1612
+ const gutter = TOOLTIP_VIEWPORT_GUTTER_PX;
1613
+ // `Math.max` on the ceiling: a popover WIDER than the viewport has no valid
1614
+ // resting place, and pinning it to the leading gutter beats a negative range.
1615
+ const clamped = Math.min(Math.max(ideal, gutter), Math.max(gutter, viewport - gutter - width));
1616
+ const shift = clamped - ideal;
1617
+ // The caret undoes the shift so it stays over the icon — but only as far as the
1618
+ // panel's own corner; see TOOLTIP_CARET_INSET_PX.
1619
+ const limit = Math.max(0, width / 2 - TOOLTIP_CARET_INSET_PX);
1620
+ const caret = Math.min(Math.max(-shift, -limit), limit);
1621
+ this.setTooltipOffsets(tooltip, shift, caret);
1622
+ }
1623
+ catch (error) {
1624
+ // A popover that could not be measured still renders, centred — never let
1625
+ // positioning cost the content.
1626
+ this.safeLog('positioning the offer details popover failed', error);
1627
+ }
1628
+ }
1629
+ setTooltipOffsets(tooltip, shift, caret) {
1630
+ tooltip.style?.setProperty?.('--s-conditional-offer-tooltip-shift', `${Math.round(shift)}px`);
1631
+ tooltip.style?.setProperty?.('--s-conditional-offer-tooltip-caret', `${Math.round(caret)}px`);
1632
+ }
1633
+ /**
1634
+ * A resize changes which edge the popover is up against, so the clamp has to be
1635
+ * re-derived. Cheap: `positionTooltip` returns on its first line unless the popover is
1636
+ * actually open.
1637
+ */
1638
+ handleWindowResize() {
1639
+ this.positionTooltip();
1640
+ }
1641
+ handleDocumentKeydown(event) {
1642
+ // `Esc` is IE/old-Edge; harmless to keep and free to check.
1643
+ if (event.key !== 'Escape' && event.key !== 'Esc')
1644
+ return;
1645
+ this.closeTooltip(true);
1646
+ }
1647
+ /**
1648
+ * Outside click dismisses a PINNED tooltip. `composedPath` rather than
1649
+ * `event.target`, so a click on a slotted/nested element still resolves to the
1650
+ * anchor; `contains` is the fallback for the (test) environments that lack it.
1651
+ */
1652
+ handleDocumentClick(event) {
1653
+ if (!this.isTooltipPinned || !this.infoAnchorEl)
1654
+ return;
1655
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
1656
+ const inside = path.length
1657
+ ? path.includes(this.infoAnchorEl)
1658
+ : this.infoAnchorEl.contains(event.target);
1659
+ if (!inside)
1660
+ this.closeTooltip();
1661
+ }
1662
+ // ---------------------------------------------------------------------------------
1663
+ // Render
1664
+ // ---------------------------------------------------------------------------------
1665
+ /**
1666
+ * The `id` of one of this instance's own elements. EVERY id and every reference to it
1667
+ * (`aria-describedby`, `aria-controls`) goes through here, so the two cannot drift.
1668
+ */
1669
+ elementId(name) {
1670
+ return `s-conditional-offer-${name}-${this.instanceId}`;
1671
+ }
1672
+ /**
1673
+ * The badge row: one small circle per tier, joined by short connectors.
1674
+ *
1675
+ * Tier count is VARIABLE, so the row is a nowrap flex line that scrolls
1676
+ * horizontally inside a bounded width instead of squeezing the message beside it.
1677
+ * RTL correctness comes from the markup: a plain flex row inherits the document's
1678
+ * `dir`, so tier 1 lands on the leading edge and the scroll origin follows it in both
1679
+ * directions. Do not add physical left/right offsets here.
1680
+ */
1681
+ renderBadges(stages, progress) {
1682
+ // The ENGINE-DERIVED count, not `lastReachedIndex + 1`: when the engine has spoken
1683
+ // it counts the tiers up to the one it GRANTED, which is not what the thresholds
1684
+ // say — a cart clearing every threshold with nothing granted counts 0 of 3.
1685
+ const completed = progress.metCount;
1686
+ return (h("div", { class: "s-conditional-offer-badges", role: "img", "aria-label": this.trans('badges_summary', {
1687
+ completed: `${completed}`,
1688
+ total: `${stages.length}`,
1689
+ }) }, stages.map((discount, index) => {
1690
+ // THE STEPPER: tiers up to the one the engine GRANTED (`is_applied`), or the
1691
+ // threshold comparison when this backend sends no flag at all. Never
1692
+ // `index <= lastReachedIndex`, and never `progress.applied[index]` — a cart
1693
+ // offer pays out once, so reading the raw flag put a lock BELOW a check.
1694
+ const isMet = progress.met[index];
1695
+ const key = this.tierKey(discount, index);
1696
+ return [
1697
+ /* The met connector is a SOLID 8x1px line in the primary teal, the unmet
1698
+ one a DASHED 1px line in a dark neutral. They sit in a 16px gap
1699
+ between two 24px badges; the 4px breathing space is the row's
1700
+ `gap-1`, so the connector element itself is just the 8px rule. */
1701
+ index > 0 ? (h("span", { key: `${key}-link`, class: {
1702
+ 's-conditional-offer-badge-link': true,
1703
+ 's-conditional-offer-badge-link-met': isMet,
1704
+ 's-conditional-offer-badge-link-unmet': !isMet,
1705
+ }, "aria-hidden": "true" })) : null,
1706
+ h("span", { key: key, class: {
1707
+ 's-conditional-offer-badge': true,
1708
+ 's-conditional-offer-badge-met': isMet,
1709
+ 's-conditional-offer-badge-unmet': !isMet,
1710
+ }, "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" }))),
1711
+ ];
1712
+ })));
1713
+ }
1714
+ /**
1715
+ * Spec §4 — the ⓘ popover: title, the merchant's offer message, then one row per tier.
1716
+ *
1717
+ * The prose is `details.rules`, NEVER `offer.description` — see `readOfferRules`.
1718
+ */
1719
+ renderTooltip(stages, progress) {
1720
+ const description = readOfferRules(this.offer);
1721
+ 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) => {
1722
+ // Same single source of truth as the badge row — see `renderBadges`.
1723
+ const isMet = progress.met[index];
1724
+ 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: {
1725
+ 's-conditional-offer-tier-icon': true,
1726
+ 's-conditional-offer-tier-icon-met': isMet,
1727
+ 's-conditional-offer-tier-icon-unmet': !isMet,
1728
+ } }, h("i", { class: this.getRewardIcon(discount), "aria-hidden": "true" }), h("span", { class: {
1729
+ 's-conditional-offer-tier-status': true,
1730
+ 's-conditional-offer-tier-status-met': isMet,
1731
+ 's-conditional-offer-tier-status-unmet': !isMet,
1732
+ }, "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: {
1733
+ 's-conditional-offer-tier-text': true,
1734
+ 's-conditional-offer-tier-text-met': isMet,
1735
+ }, innerHTML: this.getTierSentence(discount, index, progress) }))));
1736
+ }))));
1737
+ }
1738
+ /**
1739
+ * "ادارة العرض". Rendered TWICE with complementary responsive visibility rather than
1740
+ * moved with `order:`: desktop wants it inline with the banner row, mobile wants a
1741
+ * full-width bar BELOW the gifts card, and those are different places
1742
+ * in the flow, not a different order within one flex line.
1743
+ */
1744
+ renderGiftsToggle(variant) {
1745
+ return (h("button", { type: "button", id: this.elementId(`toggle-${variant}`), class: {
1746
+ 's-conditional-offer-toggle': true,
1747
+ 's-conditional-offer-toggle-inline': variant === 'inline',
1748
+ 's-conditional-offer-toggle-bar': variant === 'bar',
1749
+ }, "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: {
1750
+ 'sicon-keyboard_arrow_down': true,
1751
+ 's-conditional-offer-toggle-icon': true,
1752
+ 's-conditional-offer-toggle-icon-open': this.isGiftsOpen,
1753
+ }, "aria-hidden": "true" })));
1754
+ }
1755
+ /** Spec §5 — the gifts card: one read-only row per gift line. */
1756
+ renderGiftsCard() {
1757
+ 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)))));
1758
+ }
1759
+ renderGiftRow(gift) {
1760
+ 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 ? (
1761
+ // innerHTML: `salla.money()` markup again.
1762
+ h("span", { class: "s-conditional-offer-gift-price", innerHTML: salla.money(gift.originalPrice) })) : null))));
140
1763
  }
141
1764
  getLoadingSkeletonView() {
142
- 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" }))])))));
1765
+ 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%" }))))));
143
1766
  }
144
1767
  render() {
145
1768
  if (!this.canRender)
146
1769
  return null;
1770
+ /**
1771
+ * HIDDEN IS THE DEFAULT. Nothing renders — no banner, no skeleton, no host class,
1772
+ * no layout at all — until it is POSITIVELY KNOWN that the cart holds at least one
1773
+ * item. Everything else, including "the lookup has not answered yet" and "the
1774
+ * lookup failed", is hidden.
1775
+ *
1776
+ * This is an inversion of the original tri-state, and it is what makes the whole
1777
+ * class of stuck-block bugs unreachable rather than merely unlikely. The two
1778
+ * failure modes are not symmetric: hiding when we should have shown is INVISIBLE
1779
+ * and self-corrects the moment the count lands, while showing when we should have
1780
+ * hidden is a tinted block sitting above "السلة فارغة" — which is what shipped
1781
+ * twice. An unknown cart is indistinguishable from an empty one to the shopper, so
1782
+ * it is treated as one.
1783
+ *
1784
+ * Note what this makes structurally impossible: a skeleton can only ever be
1785
+ * painted once `cartItemCount >= 1`, i.e. only when there is genuinely content on
1786
+ * the way, so a skeleton can no longer be stranded by anything that fails before
1787
+ * the cart is known.
1788
+ */
1789
+ if (!this.hasCartItems())
1790
+ return null;
1791
+ // Past this line the cart is known to hold items, so the skeleton is a REAL
1792
+ // loading state for content we know is coming — and the watchdog still sits
1793
+ // behind it for the case where the offer request never settles.
147
1794
  if (this.isLoading)
148
1795
  return this.getLoadingSkeletonView();
149
1796
  if (!this.offer)
150
1797
  return null;
151
- 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))))));
1798
+ // Derived ONCE per render and threaded down — `stages[i]` and
1799
+ // `progress.thresholds[i]` must be the same pair everywhere they are read.
1800
+ const stages = this.getStages();
1801
+ const progress = this.getProgress(stages);
1802
+ const guidance = this.getGuidanceMessage(stages, progress);
1803
+ const detailsTitle = this.trans('details_title');
1804
+ // Spec §2 — the accordion exists only when there is something to manage.
1805
+ const hasGifts = this.gifts.length > 0;
1806
+ 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 ? (
1807
+ // innerHTML: the `:remaining` half carries `salla.money()` markup;
1808
+ // the reward half is escaped in `getGuidanceMessage`.
1809
+ 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));
152
1810
  }
153
1811
  static get is() { return "salla-conditional-offer"; }
154
1812
  static get originalStyleUrls() {
@@ -164,9 +1822,35 @@ export class SallaConditionalOffer {
164
1822
  static get states() {
165
1823
  return {
166
1824
  "offer": {},
167
- "products": {},
168
1825
  "isLoading": {},
169
- "canRender": {}
1826
+ "canRender": {},
1827
+ "gifts": {},
1828
+ "cartItemCount": {},
1829
+ "isTooltipHovered": {},
1830
+ "isTooltipPinned": {},
1831
+ "isGiftsOpen": {}
170
1832
  };
171
1833
  }
1834
+ static get elementRef() { return "hostEl"; }
1835
+ static get listeners() {
1836
+ return [{
1837
+ "name": "resize",
1838
+ "method": "handleWindowResize",
1839
+ "target": "window",
1840
+ "capture": false,
1841
+ "passive": true
1842
+ }, {
1843
+ "name": "keydown",
1844
+ "method": "handleDocumentKeydown",
1845
+ "target": "document",
1846
+ "capture": false,
1847
+ "passive": false
1848
+ }, {
1849
+ "name": "click",
1850
+ "method": "handleDocumentClick",
1851
+ "target": "document",
1852
+ "capture": false,
1853
+ "passive": false
1854
+ }];
1855
+ }
172
1856
  }