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