PageSourceSearch

https://www.packsol.kr/js/site_shop/site_shop_free_ship_notify_sdk.js?1785306771

js packsol.kr collected 2026-10-02 01:05:40 UTC 64,236 bytes, 1,263 lines download raw bytes

1window.IMWEB_SHOP_FREE_SHIP_NOTIFY_SDK = (function () {
2
3    // magnet-shell custom element를 페이지 로드 시점에 미리 등록한다.
4    // 동적 주입되는 모달 HTML(get_order_alarm_modal / get_add_cart_alarm_modal / get_product_option_modal) 안에
5    // 같은 import 스크립트가 있지만 innerHTML/append로 들어가면 브라우저가 <script>를 실행하지 않으므로,
6    // SDK 진입 시점에 한 번 register하여 동적 magnet-shell이 자동 upgrade되도록 보장한다.
7    // customElements.define은 idempotent하여 모달 측 인라인 import가 다시 호출되어도 무해.
8    function patchMagnetShell(MagnetShellClass) {
9        if (!MagnetShellClass || MagnetShellClass.__imwebManifestUrlPatched) {
10            return;
11        }
12
13        var proto = MagnetShellClass.prototype;
14        var connectedCallback = proto.connectedCallback;
15        var resetMangetNode = proto.resetMangetNode;
16        var attributeChangedCallback = proto.attributeChangedCallback;
17
18        function syncManifestUrl(element) {
19            element.manifestUrl = element.manifestUrl || element.getAttribute('data-manifest-url') || element.getAttribute('manifest-url') || '';
20        }
21
22        function executeProductRecommendationModuleScripts(element) {
23            if (!element.classList || !element.classList.contains('foProductRecommendationMagnet')) {
24                return;
25            }
26
27            var scripts = element.querySelectorAll('script[type="module"][src]');
28            Array.prototype.forEach.call(scripts, function (script) {
29                var src = script.getAttribute('src') || '';
30                if (!src || src.indexOf('/_/fo-shopping/assets/product-recommendation-') === -1 || script.getAttribute('data-imweb-executed') === 'Y') {
31                    return;
32                }
33
34                script.setAttribute('data-imweb-executed', 'Y');
35
36                var executedScript = document.createElement('script');
37                executedScript.type = 'module';
38                executedScript.src = src;
39                if (script.getAttribute('crossorigin') !== null) {
40                    executedScript.setAttribute('crossorigin', script.getAttribute('crossorigin') || '');
41                }
42                document.head.appendChild(executedScript);
43            });
44        }
45
46        proto.connectedCallback = function () {
47            syncManifestUrl(this);
48            return connectedCallback.apply(this, arguments);
49        };
50
51        proto.resetMangetNode = function () {
52            syncManifestUrl(this);
53            var result = resetMangetNode.apply(this, arguments);
54            if (result && typeof result.then === 'function') {
55                return result.then(function (value) {
56                    executeProductRecommendationModuleScripts(this);
57                    return value;
58                }.bind(this));
59            }
60
61            executeProductRecommendationModuleScripts(this);
62            return result;
63        };
64
65        proto.attributeChangedCallback = function (name, oldValue, newValue) {
66            if (name === 'data-manifest-url' || name === 'manifest-url') {
67                this.manifestUrl = newValue || this.getAttribute('data-manifest-url') || this.getAttribute('manifest-url') || '';
68                if (typeof this.clearManifest === 'function') {
69                    this.clearManifest();
70                }
71                return;
72            }
73            if (typeof attributeChangedCallback === 'function') {
74                return attributeChangedCallback.apply(this, arguments);
75            }
76        };
77
78        MagnetShellClass.__imwebManifestUrlPatched = true;
79    }
80
81    if (typeof customElements !== 'undefined') {
82        var registeredMagnetShell = customElements.get('magnet-shell');
83        if (registeredMagnetShell) {
84            patchMagnetShell(registeredMagnetShell);
85        } else {
86            import('https://static.imweb.me/design-system/magnet/magnet-shell.js')
87                .then(function (module) {
88                    var currentMagnetShell = customElements.get('magnet-shell');
89                    if (currentMagnetShell) {
90                        patchMagnetShell(currentMagnetShell);
91                        return;
92                    }
93
94                    var BaseMagnetShell = module.MagnetShell;
95                    class ImwebMagnetShell extends BaseMagnetShell {
96                        static get observedAttributes() {
97                            var observedAttributes = BaseMagnetShell.observedAttributes || [];
98                            return observedAttributes.indexOf('data-manifest-url') === -1
99                                ? observedAttributes.concat('data-manifest-url')
100                                : observedAttributes;
101                        }
102                    }
103
104                    customElements.define('magnet-shell', ImwebMagnetShell);
105                    patchMagnetShell(ImwebMagnetShell);
106                })
107                .catch(console.warn);
108        }
109    }
110
111    // 장바구니 페이지의 주문 알림 모달(get_order_alarm_modal)은 브라우저 ì
111„¸ì…˜ 1회만 노출한다.
112    // IMWEB_SESSIONSTORAGE(vendor/js/common.js) 는 같은 탭의 페이지 이동에도 살아남으므로 재진입해도 다시 뜨지 않음.
113    // 탭 종료 / 새 탭 진입 / 시크릿창 종료 시점에 자연스럽게 리셋되고, 로그아웃/로그인 시 logout.cm / backpg/login.cm 에서 명시적으로 제거된다.
114    // 스토리지가 막힌 환경(IMWEB_SESSIONSTORAGE 미평가 등)에서는 노출하지 않는다 — 이미 본 것으로 간주.
115    //
116    // 한편 상품 상세의 장바구니 담기 알림 모달(get_add_cart_alarm_modal)은 의도적으로 페이지뷰 1회 정책을 유지 —
117    // 상품을 둘러보며 여러 번 담는 흐름에서 매번 알림을 주는 사용성이 필요하다.
118    var _SESSION_KEY_ORDER_ALARM_SHOWN = 'imweb:freeShipNotify:orderAlarmShown';
119    var _addCartAlarmShownInThisPageview = false;
120
121    // SDK 는 일반 script 로 즉시 평가되지만 mfe 번들은 module 로 defer 로드되어,
122    // POPUP 단독 모드에서는 cart_list_make 응답의 cartTotal:update / cartFreeShippingRemainingPrice:update
123    // dispatch 가 mfe 모듈 평가보다 먼저 발생해 mfe store 가 이벤트를 놓치는 케이스가 있다.
124    // SDK 가 두 이벤트를 listen 해 마지막 값을 캐싱하고, mfe store 가 모듈 평가 시점에
125    // 이 캐시에서 초기값을 hydrate 하여 dispatch 누락에도 일관된 값을 갖도록 한다.
126    var _cachedCartTotal = null;
127    var _cachedCartFreeShippingRemainingPrice = null;
128    var _cachedSelectedOptionsTotal = null;
129    var _cachedFreeShipNotifySettings = null;
130    window.addEventListener('imweb:freeShipNotify:cartTotal:update', function (e) {
131        var detail = e && e.detail ? e.detail : {};
132        if (typeof detail.totalPrice === 'number') {
133            _cachedCartTotal = detail.totalPrice;
134        }
135    });
136    window.addEventListener('imweb:freeShipNotify:cartFreeShippingRemainingPrice:update', function (e) {
137        var detail = e && e.detail ? e.detail : {};
138        if (typeof detail.remainingPrice === 'number') {
139            _cachedCartFreeShippingRemainingPrice = detail.remainingPrice;
140        }
141    });
142    // 상품 상세 진입 시 site_shop.js 의 initDetail 이 옵션 없는 상품에 한해
143    // 무료배송 기준 상품 금액을 selectedOptions:update 로 즉시 dispatch 한다.
144    // 이 dispatch 가 mfe 모듈 평가보다 빠를 수 있어 cartTotal 과 동일하게 SDK 가 캐싱한다.
145    window.addEventListener('imweb:freeShipNotify:selectedOptions:update', function (e) {
146        var detail = e && e.detail ? e.detail : {};
147        if (typeof detail.totalPrice === 'number') {
148            _cachedSelectedOptionsTotal = detail.totalPrice;
149        }
150    });
151
152    /**
153     * mfe store 가 모듈 평가 시점에 호출. dispatch 누락 케이스에 초기값으로 사용된다.
154     * @returns {number|null}
155     */
156    function getCachedCartTotal() {
157        return _cachedCartTotal;
158    }
159    /**
160     * mfe store 가 모듈 평가 시점에 호출. dispatch 누락 케이스에 초기값으로 사용된다.
161     * @returns {number|null}
162     */
163    function getCachedCartFreeShippingRemainingPrice() {
164        return _cachedCartFreeShippingRemainingPrice;
165    }
166    /**
167     * mfe store 가 모듈 평가 시점에 호출. dispatch 누락 케이스에 초기값으로 사용된다.
168     * @returns {number|null}
169     */
170    function getCachedSelectedOptionsTotal() {
171        return _cachedSelectedOptionsTotal;
172    }
173    /**
174     * mfe store 가 모듈 평가 시점에 호출. BO 미리보기 settings:update 가 MFE 마운트보다 먼저 발생한 경우의 초기값으로 사용된다.
175     * @returns {object|null}
176     */
177    function getCachedFreeShipNotifySettings() {
178        return _cachedFreeShipNotifySettings;
179    }
180
181    // 매출상승도구 미리보기(sales_tool_preview=freeShipNotify) 진입 시에는 페이지뷰 1회 제한을 우회한다.
182    // BO에서 모달 미리보기를 반복 확인할 수 있어야 하므로 has*AlarmShown은 항상 false를 반환.
183    // 초기화 시점에 1회 평가해 closure에 보관하면 매 호출마다 URL 파싱 비용을 들이지 않아도 된다.
184    var _isSalesToolFreeShipNotifyPreview = (function () {
185        try {
186            var params = new URLSearchParams(window.location.search);
187            return params.get('sales_tool_preview') === 'freeShipNotify';
188        } catch (e) {
189            return false;
190        }
191    })();
192
193    if (_isSalesToolFreeShipNotifyPreview) {
194        var isAllowedSalesToolPreviewOrigin = function (origin) {
195            if (origin === window.location.origin) return true;
196            return /^https:\/\/[a-z0-9-]+\.crm\.(imweb|imtest)\.me$/.test(origin);
197        };
198
199        // 누적 타겟 위치를 두고 RAF 마다 잔여 거리의 일부씩 좁히는 ease-out 방식.
200        // wheel 이 빠르게 연속으로 들어와도 매번 즉시 scrollBy 하지 않고 target 에 누적만 하므로
201        // behavior:'smooth' 가 만드는 큐잉/끊김 없이 매끄러운 관성 스크롤로 보인다.
202        // 애니메이션이 멈춰서 target=null 이 되면 다음 wheel 에서 현재 스크롤 위치로 재동기화 — 외부 스크롤 변경(앵커 이동 등)과 자연스럽게 정합.
203        var smoothScrollState = { targetX: null, targetY: null, rafId: 0 };
204        var SMOOTH_SCROLL_EASE = 0.2;
205
206        var tickSmoothScroll = function () {
207            var s = smoothScrollState;
208            var dy = s.targetY - window.scrollY;
209            var dx = s.targetX - window.scrollX;
210            if (Math.abs(dy) < 0.5 && Math.abs(dx) < 0.5) {
211                window.scrollTo(s.targetX, s.targetY);
212                s.rafId = 0;
213                s.targetX = null;
214                s.targetY = null;
215                return;
216            }
217            window.scrollBy(dx * SMOOTH_SCROLL_EASE, dy * SMOOTH_SCROLL_EASE);
218            s.rafId = requestAnimationFrame(tickSmoothScroll);
219        };
220
221        window.addEventListener('message', function (event) {
222            if (!isAllowedSalesToolPreviewOrigin(event.origin)) return;
223            var data = event.data;
224            if (!data || typeof data !== 'object') return;
225            if (data.type !== 'sales-tool-preview:wheel') return;
226            var deltaY = typeof data.deltaY === 'number' ? data.deltaY : 0;
227            var deltaX = typeof data.deltaX === 'number' ? data.deltaX : 0;
228
229            var s = smoothScrollState;
230            if (s.targetY === null) {
231                s.targetY = window.scrollY;
232                s.targetX = window.scrollX;
233            }
234            var maxY = Math.max(0, (document.documentElement.scrollHeight || 0) - window.innerHeight);
235            var maxX = Math.max(0, (document.documentElement.scrollWidth || 0) - window.innerWidth);
236            s.targetY = Math.max(0, Math.min(maxY, s.targetY + deltaY));
237            s.targetX = Math.max(0, Math.min(maxX, s.targetX + deltaX));
238            if (!s.rafId) {
239                s.rafId = requestAnimationFrame(tickSmoothScroll);
240            }
241        });
242    }
243
244    /**
245     * 장바구니 주문 알림 모달이 이번 브라우저 세션(같은 탭의 페이지 이동 포함) 안에서 이미 노출됐는지 여부.
246     * IMWEB_SESSIONSTORAGE 미평가 등 스토리지 접근 불가 시에는 노출을 보류한다 (이미 본 것으로 간주).
247     * 미리보기 모드에서는 매번 false를 반환해 1회 제한을 우회한다.
248     * @returns {boolean}
249     */
250    function hasOrderAlarmShown() {
251        if (_isSalesToolFreeShipNotifyPreview) return false;
252        if (typeof IMWEB_SESSIONSTORAGE === 'undefined') return true;
253        return IMWEB_SESSIONSTORAGE.get(_SESSION_KEY_ORDER_ALARM_SHOWN) === true;
254    }
255
256    /**
257     * 장바구니 주문 알림 모달이 노출됐음을 기록. AJAX로 모달 HTML을 fetch하기 직전에 호출한다.
258     * sessionStorage 에 기록해 같은 탭의 다른 페이지로 이동해도 재노출되지 않게 한다.
259     */
260    function markOrderAlarmShown() {
261        if (typeof IMWEB_SESSIONSTORAGE === 'undefined') return;
262        try {
263            IMWEB_SESSIONSTORAGE.set(_SESSION_KEY_ORDER_ALARM_SHOWN, true);
264        } catch (e) {
265            // sessionStorage quota 초과 등 — 무시. 1회 노출 추적이 불완전해질 뿐 사용자 흐름에는 영향 없음.
266        }
267    }
268
269    /**
270     * 상품 상세 장바구니 담기 알림 모달이 이번 페이지뷰에서 이미 노출됐는지 여부.
271     * 미리보기 모드에서는 매번 false를 반환해 1회 제한을 우회한ë
271‹¤.
272     * @returns {boolean}
273     */
274    function hasAddCartAlarmShown() {
275        if (_isSalesToolFreeShipNotifyPreview) return false;
276        return _addCartAlarmShownInThisPageview;
277    }
278
279    /**
280     * 상품 상세 장바구니 담기 알림 모달이 노출됐음을 기록. AJAX로 모달 HTML을 fetch하기 직전에 호출한다.
281     */
282    function markAddCartAlarmShown() {
283        _addCartAlarmShownInThisPageview = true;
284    }
285
286    /**
287     * 장바구니 페이지 무료배송 안내 마그넷에 무료배송까지 남은 금액(remainingPrice)을 셋팅하고
288     * wrapper(magnet-shell의 parent)의 tw-hidden을 토글한다.
289     *
290     * cart.sub은 초기 렌더 시 실 데이터가 없어 wrapper가 tw-hidden 상태로 시작하고,
291     * get_cart_price.cm / OMS_change_cart_item.cm 응답에 free_shipping_remaining_price가 포함되면 이 함수를 통해 노출된다.
292     * MFE에는 CustomEvent로 값을 전달하여 자체 state를 갱신하도록 한다.
293     * MFE는 이 값으로 게이지/메시지를 표시하고, 진행도 계산에 필요한 threshold는 (remainingPrice + currentTotal)로 역산한다.
294     * @param {number|string|null|undefined} remainingPrice - 무료배송까지 남은 금액. 유효 범위(>=0)일 때만 노출.
295     */
296    function setCartFreeShippingRemainingPrice(remainingPrice) {
297        // TOP_FIXED 마그넷과 POPUP 마커는 BO 설정(shopCartStyle)에 따라 독립적으로 존재한다.
298        //   - TOP_FIXED 모드: 마그넷 wrap만 출력 (POPUP 마커 없음)
299        //   - POPUP 모드: POPUP 마커만 출력 (TOP_FIXED 마그넷 없음)
300        // 어느 한 쪽만 있을 때 다른 쪽 처리가 영향받지 않도록 두 요소를 독립적으로 다룬다.
301        var magnet = document.getElementById('foFreeShipNotifyCartPageMagnet');
302        var magnetWrap = magnet ? magnet.parentElement : null;
303        var popupMarker = document.getElementById('shop_cart_order_alarm_with_free_ship_notify');
304
305        // 응답 누락/무효 값: wrapper와 POPUP 마커를 함께 비활성화해 이전 선택값이 남지 않게 한다.
306        if (remainingPrice === undefined || remainingPrice === null) {
307            if (magnetWrap) magnetWrap.classList.add('tw-hidden');
308            if (popupMarker) popupMarker.setAttribute('data-init-deliv-price-flexable-key', '0');
309            return;
310        }
311        // 달러 등 소수점 통화 지원을 위해 parseFloat 사용.
312        var numericRemaining = typeof remainingPrice === 'number' ? remainingPrice : parseFloat(remainingPrice);
313        if (isNaN(numericRemaining) || numericRemaining < 0) {
314            if (magnetWrap) magnetWrap.classList.add('tw-hidden');
315            if (popupMarker) popupMarker.setAttribute('data-init-deliv-price-flexable-key', '0');
316            return;
317        }
318
319        if (magnetWrap) magnetWrap.classList.remove('tw-hidden');
320
321        // POPUP 마커의 data-init-deliv-price-flexable-key를 실 데이터로 동기화.
322        // site_shop.js:addOrderWithCart가 마커값 > 0일 때만 모달을 띄우므로, 실 데이터를 반영해
323        // 일반 모드에서도 정상적으로 모달이 노출되도록 한다. 무료배송 충족(0)이면 자연스럽게 모달이 뜨지 않는다.
324        if (popupMarker) {
325            popupMarker.setAttribute('data-init-deliv-price-flexable-key', String(numericRemaining));
326        }
327
328        window.dispatchEvent(new CustomEvent('imweb:freeShipNotify:cartFreeShippingRemainingPrice:update', {
329            detail: { remainingPrice: numericRemaining }
330        }));
331    }
332
333    /**
334     * 상품 상세 인라인 마그넷의 노출 여부를 필수 옵션 선택 완료 상태에 따라 토글한다.
335     * shopDetailStyle === 'require_option'으로 렌더된 wrapper만 대상. site_shop.js의
336     * updateSelectedOptions에서 옵션 선택/해제 시마다 호출한다. 다른 스타일에서는 wrapper의
337     * data-shop-detail-style이 다르거나 wrapper 자체가 없어 no-op로 안전하게 동작한다.
338     * @param {boolean} isCompleted - 필수 옵션이 모두 선택되었는지 여부
339     */
340    function setRequireOptionCompleted(isCompleted) {
341        var wrap = document.querySelector('._free_ship_notify_detail_magnet_wrap[data-shop-detail-style="require_option"]');
342        if (!wrap) return;
343        wrap.style.display = isCompleted ? '' : 'none';
344    }
345
346    /**
347     * 무료배송 안내 모달 버튼 등 PHP에서 렌더된 영역의 강조 색상은 CSS 변수
348     * --free-ship-notify-accent 로 inline 참조한다. PHP가 페이지 렌더 시점에 BO 설정값을 인자로 넘겨 호출하면
349     * documentElement에 세팅돼 AJAX로 나중에 주입되는 모달까지 모두 cascade로 받는다.
350     *
351     * 빈 문자열이 들어오면 변수를 제거(removeProperty)하여 `var(--free-ship-notify-accent, ...)`의
352     * 폴백 체인(브랜드 컬러 등)이 동작하도록 한다. 빈 문자열로 setProperty하면 빈 값이 적용되어
353     * 폴백이 동작하지 않으므로 명시적으로 제거하는 것이 중요하다.
354     * @param {string} accentColor - BO 설정의 accentColor. 빈 문자열이면 변수 제거.
355     */
356    function setAccentColor(accentColor) {
357        if (typeof accentColor !== 'string') return;
358        if (accentColor.length === 0) {
359            document.documentElement.style.removeProperty('--free-ship-notify-accent');
360            return;
361        }
362        document.documentElement.style.setProperty('--free-ship-notify-accent', accentColor);
363    }
364
365    // settings:update 이벤트(BO sales-tool-preview 등 동적 갱신)는 같은 변수를 덮어쓴다.
366    window.addEventListener('imweb:freeShipNotify:settings:update', function (event) {
367        var detail = (event && event.detail) || {};
368        _cachedFreeShipNotifySettings = Object.assign({}, _cachedFreeShipNotifySettings || {}, detail);
369        setAccentColor(detail.accentColor);
370    });
371
372    /**
373     * MFE 컴포넌트로 선택된 상품 옵션 총액 정보 전송
374     * @param {number} totalPrice - 선택된 옵션 총 금액
375     */
376    function sendSelectedOptionsUpdate(totalPrice) {
377        try {
378            const event = new CustomEvent('imweb:freeShipNotify:selectedOptions:update', {
379                detail: {
380                    totalPrice: totalPrice || 0
381                }
382            });
383            
384            // 전역으로 이벤트 발송
385            window.dispatchEvent(event);
386            
387        } catch (error) {
388        }
389    }
390    
391    /**
392     * 상품 상세 페이지에서 선택된 옵션 총액 업데이트 전송
393     * @param {number} total_price - 선택된 옵션 총 금액
394     */
395    function updateSelectedOptionsTotal(total_price) {
396        sendSelectedOptionsUpdate(total_price || 0);
397    }
398    
399    // load_state.cm 응답에 묶여 오는 BO 업셀 config 의 캐시.
400    // mfe 모듈 평가 시점이 SDK fetch 보다 늦을 수 있어 listener 미등록 구간의 누락을 보정하기 위함.
401    // sharedCurrentTotal 의 getCachedCartTotal 패턴과 동일.
402    var _cachedFreeShipNotifyConfig = null;
403
404    /**
405     * MFE 컴포넌트로 상품 추천 목록 + BO 업셀 config 전송.
406     *
407     * 응답 안의 config 는 detail 의 sibling 키로 분리되어 dispatch 된다 (의미상 별개 도메인).
408     * 기존 consumer 는 detail.recommendations.prod_list 만 읽고 있어 sibling 추가는 호환됨.
409     *
410     * @param {object} recommendations - { prod_list, config } shape (load_state.cm 응답의 prod_recommendation)
411     */
412    function sendProductRecommendations(recommendations) {
413        try {
414            var payload = recommendations || {};
415            var config  = payload.config || null;
416            if (config) {
417                _cachedFreeShipNotifyConfig = config;
418                // PHP 모달 버튼 등 magnet-shell 밖의 DOM 이 var(--free-ship-notify-accent, ...) 로 참조하므로
419                // ajax 응답으로 config 가 들어오면 documentElement 의 CSS 변수도 같이 갱신해 PHP 쪽 setAccentColor 호출 의존을 제거.
420                if (typeof config.accentColor === 'string') {
421                    setAccentColor(config.accentColor);
422                }
423            }
424            const event = new CustomEvent('imweb:freeShipNotify:recommendations:update', {
425                detail: {
426                    recommendations: { prod_list: payload.prod_list || [] },
427                    config:          config,
428                }
429            });
430
431            // 전역으로 이벤트 발송
432            window.dispatchEvent(event);
433
434        } catch (error) {
435        }
436    }
437
438    /**
439     * 추천 상품 카트 추가 완료 신호 — MFE 가 BrandScope click_complete_add_free_shipping_upsell 발화에 사용.
440     * 옵션 없음 / 있음, prod / cart 4 케이스 모두 동일 이벤트로 통합.
441     */
442    function dispatchAdditionalProductAdded(prodIdx, result) {
443        try {
444            var cartCode = result && typeof result.cart_code === 'string' ? result.cart_code : '';
445            window.dispatchEvent(new CustomEvent('imweb:freeShipNotify:additionalProductAdded', {
446                detail: { prodIdx: prodIdx, cartCode: cartCode, cart_code: cartCode }
447            }));
448        } catch (e) {
449        }
450    }
451
452    var _inlineMagnetCodeByIdx = {};
453
454    function dispatchInlineMagnetItemCounts() {
455        if (typeof window.__imwebFreeShipInlineItemCounts !== 'object' || window.__imwebFreeShipInlineItemCounts === null) {
456            window.__imwebFreeShipInlineItemCounts = {};
457        }
458        window.dispatchEvent(new CustomEvent('imweb:freeShipNotify:cartItems:update', {
459            detail: { counts: Object.assign({}, window.__imwebFreeShipInlineItemCounts) }
460        }));
461    }
462
463    function incrementInlineMagnetItemCount(code, idx) {
464        if (typeof code !== 'string' || code === '') return;
465        if (typeof window.__imwebFreeShipInlineItemCounts !== 'object' || window.__imwebFreeShipInlineItemCounts === null) {
466            window.__imwebFreeShipInlineItemCounts = {};
467        }
468        window.__imwebFreeShipInlineItemCounts[code] = (window.__imwebFreeShipInlineItemCounts[code] || 0) + 1;
469        if (typeof idx === 'number' && idx > 0) {
470            _inlineMagnetCodeByIdx[idx] = code;
471        }
472        dispatchInlineMagnetItemCounts();
473    }
474
475    function resetInlineMagnetItemCounts() {
476        window.__imwebFreeShipInlineItemCounts = {};
477        dispatchInlineMagnetItemCounts();
478    }
479
480    function getInlineMagnetCodeByIdx() {
481        return Object.assign({}, _inlineMagnetCodeByIdx);
482    }
483
484    function syncInlineMagnetItemCountsFromAdditionals(additionalsList) {
485        if (!Array.isArray(additionalsList)) return;
486        var nextCounts = {};
487        for (var i = 0; i < additionalsList.length; i++) {
488            var item = additionalsList[i];
489            if (!item || typeof item.idx !== 'number') continue;
490            var code = _inlineMagnetCodeByIdx[item.idx];
491            if (typeof code !== 'string' || code === '') continue;
492            var count = (typeof item.count === 'number' && item.count > 0) ? item.count : 0;
493            if (count > 0) {
494                nextCounts[code] = count;
495            }
496        }
497        window.__imwebFreeShipInlineItemCounts = nextCounts;
498        dispatchInlineMagnetItemCounts();
499    }
500
501    /**
502     * 모듈 평가 시점 hydration 용 — 마지막으로 받은 BO 업셀 config 스냅샷을 동기로 반환.
503     * @returns {object|null}
504     */
505    function getCachedFreeShipNotifyConfig() {
506        return _cachedFreeShipNotifyConfig;
507    }
508
509    function getProductRecommendationElement() {
510        return document.querySelector('fo-shopping-product-recommendation');
511    }
512
513    function isProductRecommendationContext() {
514        return !!(
515            document.getElementById('foProductRecommendationAddCartAlertMagnet')
516            || document.getElementById('foProductRecommendationCartPageMagnet')
517            || getProductRecommendationElement()
518        );
519    }
520
521    function getProductRecommendationContext(fallback_context) {
522        if (fallback_context === 'cart' || fallback_context === 'prod') {
523            return fallback_context;
524        }
525
526        var productRecommendationElement = getProductRecommendationElement();
527        var productRecommendationShell = productRecommendationElement
528            ? productRecommendationElement.closest('magnet-shell')
529            : (
530                document.getElementById('foProductRecommendationCartPageMagnet')
531                || document.getElementById('foProductRecommendationAddCartAlertMagnet')
532            );
533        var context = productRecommendationShell ? productRecommendationShell.getAttribute('data-type') : null;
534        return context === 'cart' ? 'cart' : 'prod';
535    }
536    
537    /**
538     * MFE 컴포넌트로 상품 옵션 HTML 정보 전송
539     * @param {object} optionData - 상품 옵션 HTML 및 관련 정보
540     */
541    function sendProductOptionHtml(optionData) {
542        try {
543            const event = new CustomEvent('imweb:freeShipNotify:optionHtml:update', {
544                detail: optionData || {}
545            });
546            
547            // 전역으로 이벤트 발송
548            window.dispatchEvent(event);
549            
550        } catch (error) {
551        }
552    }
553    
554    /**
555     * 무료배송 안내 위젯의 초기 상태(추천 상품 + BO 업셀 config)를 한 요청으로 로드.
556     * @param {number} [prod_idx] - 상품 인덱스. 생략 시 cart 맥락으로 간주하여 중복 상품 제외 없이 추천 목록을 반환한다.
557     */
558    function loadState(prod_idx, context, prod_code, prod_codes) {
559        var hasProdIdx = !!prod_idx && prod_idx > 0;
560        var hasProductRecommendationContext = context === 'cart' || context === 'prod';
561        var isProductRecommendation = hasProductRecommendationContext && isProductRecommendationContext();
562        var url = isProductRecommendation
563            ? '/shop/product_recommendation/load_state.cm'
564            : '/shop/free_ship_notify/load_state.cm';
565        var requestData = hasProdIdx ? {'prod_idx': prod_idx} : {};
566
567        if (isProductRecommendation) {
568            requestData.context = getProductRecommendationContext(context);
569            if (typeof prod_code === 'string' && prod_code !== '') {
570                requestData.prod_code = prod_code;
571            }
572            if (Array.isArray(prod_codes) && prod_codes.length > 0) {
573                requestData.prod_codes = prod_codes;
574            }
575        }
576
577        $.ajax({
578            type: 'POST',
579            // prod_idx는 현재 추후 중복 상품 제외 용도로만 사용되므로 유효할 때만 전송
580            data: requestData,
581            url: url,
582            dataType: 'json',
583            cache: false,
584            success: function(result) {
585
586                if (result.msg === 'SUCCESS') {
587
588                    // MFE 컴포넌트로 데이터 전송 (prod_list + config 묶음)
589                    sendProductRecommendations(result.prod_recommendation);
590                }
591            },
592            error: function(xhr, status, error) {
593            }
594        });
595    }
596
597    /**
598     * BO 미리보기 전용 — 상품 코드 배열을 받아 해당 상품들의 full 데이터 + config 를 fetch.
599     * 저장된 config 와 무관하게 편집 중인 선택 상태를 즉시 미리보기에 반영하기 위한 경로.
600     * 응답 모양은 load_state.cm 과 동일 (prod_recommendation: { prod_list, config }).
601     * 편집 중인 settings 는 별도 postMessage → settings 스토어가 ajax config 를 우선 덮어쓴다.
602     * @param {string[]} prod_codes - 미리보기에 노출할 상품 코드 목록.
603     */
604    function loadStateByCodes(prod_codes) {
605        if (!Array.isArray(prod_codes) || prod_codes.length === 0) {
606            sendProductRecommendations({ prod_list: [] });
607            return;
608        }
609
610        $.ajax({
611            type: 'POST',
612            data: { 'prod_codes': prod_codes },
613            // jQuery 가 prod_codes[]=... 형태로 직렬화하도록 traditional: false (
613default) 유지.
614            url: '/shop/free_ship_notify/load_state_by_codes.cm',
615            dataType: 'json',
616            cache: false,
617            success: function(result) {
618
619                if (result.msg === 'SUCCESS') {
620                    sendProductRecommendations(result.prod_recommendation);
621                }
622            },
623            error: function(xhr, status, error) {
624            }
625        });
626    }
627    
628    /**
629     * 상품 인덱스를 통해 추가 상품 선택
630     * @param {number} product_idx - 상품 인덱스
631     * @param {'prod'|'cart'} context - 호출 맥락. 'prod'(상품 상세)은 SITE_SHOP_DETAIL, 'cart'(장바구니)는 SITE_SHOP_CART 경로로 분기한다.
632     * @param {function} callback - 완료 콜백 함수 (선택사항)
633     */
634    function selectAdditionalProductByIdx(product_idx, context, callback) {
635        if (!product_idx || product_idx <= 0) {
636            if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
637            return;
638        }
639
640        // 미지정/미지원 값인 경우 기존 동작 호환을 위해 'prod'로 fallback
641        if (context !== 'prod' && context !== 'cart') {
642            context = 'prod';
643        }
644
645        // context별로 필요한 전역 객체가 로드되어 있는지 확인
646        if (context === 'prod') {
647            if (typeof window.SITE_SHOP_DETAIL === 'undefined' || typeof window.SITE_SHOP_DETAIL.selectAdditionalProduct !== 'function') {
648                if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
649                return;
650            }
651        } else {
652            // cart 경로는 SITE_SHOP_CART에 대응 함수가 추가된 이후에 동작한다.
653            if (typeof window.SITE_SHOP_CART === 'undefined') {
654                if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
655                return;
656            }
657        }
658
659        // 상품 정보 조회 Ajax 호출
660        $.ajax({
661            type: 'POST',
662            data: {'product_idx': product_idx},
663            url: '/shop/free_ship_notify/get_additional_product_info.cm',
664            dataType: 'json',
665            cache: false,
666            success: function(result) {
667
668                if (result.msg === 'SUCCESS' && result.product_info) {
669                    const info = result.product_info;
670
671                    if (typeof info.code === 'string' && info.code !== ''
672                        && typeof info.product_idx === 'number' && info.product_idx > 0) {
673                        _inlineMagnetCodeByIdx[info.product_idx] = info.code;
674                    }
675
676                    try {
677                        if (info.has_required_options) {
678                            // 필수 옵션이 있는 경우 getProductOptionHtml 호출하여 모달 표시
679                            getProductOptionHtml(product_idx, info, context, callback);
680                        } else if (context === 'cart') {
681                            // cart 맥락: 필수 옵션이 없으므로 카트에 즉시 담기
682                            if (typeof window.SITE_SHOP_CART.addCart !== 'function') {
683                                if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
684                                return;
685                            }
686                            var addCartRequestOptions = {
687                                shipping_template_code: info.shipping_template_code || ''
688                            };
689                            if (info.is_regularly_prod === true || info.is_regularly_prod === 'Y' || info.only_regularly === true || info.only_regularly === 'Y') {
690                                addCartRequestOptions.cart_type = 'regularly';
691                            }
692                            window.SITE_SHOP_CART.addCart(info.product_idx, [], 1, function(success, message, addCartResult) {
693                                if (success) {
694                                    dispatchAdditionalProductAdded(product_idx, addCartResult);
695                                    if (callback) callback(true, '카트 담기가 완료되었습니다.', addCartResult);
696                                } else {
697                                    if (callback) callback(false, message || getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
698                                }
699                            }, addCartRequestOptions);
700                        } else {
701                            // prod 맥락: 필수 옵션이 없는 경우 직접 selectAdditionalProduct 호출
702                            if (typeof window.SITE_SHOP_DETAIL.setFreeShipNotifyProdAdditionalRegularPrice === 'function') {
703                                window.SITE_SHOP_DETAIL.setFreeShipNotifyProdAdditionalRegularPrice(info.product_idx, info.regular_price || info.price || 0);
704                            }
705                            window.SITE_SHOP_DETAIL.selectAdditionalProduct(
706                                info.product_idx,
707                                info.price,
708                                info.name,
709                                info.maximum_purchase_quantity,
710                                info.member_maximum_purchase_quantity,
711                                info.maximum_purchase_quantity_type,
712                                info.optional_limit,
713                                info.optional_limit_type,
714                                info.stock_use,
715                                info.stock,
716                                info.stock_unlimit,
717                                info.option_mix_type,
718                                info.prod_type,
719                                info.period_discount_flag,
720                                info.period_discount_data,
721                                info.period_discout_group_list,
722                                info.membership_discount,
723                                info.additional_creator_discount_data || null,
724                                true
725                            );
726
727                            incrementInlineMagnetItemCount(info.code, info.product_idx);
728
729                            dispatchAdditionalProductAdded(product_idx);
730                            if (callback) callback(true, '추가 상품 선택이 완료되었습니다.');
731                        }
732
733                    } catch (error) {
734                        if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
735                    }
736                } else {
737                    alert(getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
738                    if (callback) callback(false, result.error_msg || getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
739                }
740            },
741            error: function(xhr, status, error) {
742                alert(getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
743                if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
744            }
745        });
746    }
747
748    /**
749     * 상품 옵션 HTML을 가져와서 MFE 모달에 전송
750     * @param {number} product_idx - 상품 인덱스
751     * @param {object} product_info - 상품 정보
752     * @param {'prod'|'cart'} context - 호출 맥락
753     * @param {function} callback - 완료 콜백 함수 (선택사항)
754     */
755    function getProductOptionHtml(product_idx, product_info, context, callback) {
756        if (!product_idx || product_idx <= 0) {
757            if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
758            return;
759        }
760
761        // 미지정/미지원 값인 경우 기존 동작 호환을 위해 'prod'로 fallback
762        if (context !== 'prod' && context !== 'cart') {
763            context = 'prod';
764        }
765
766        // 1단계: 기본 모달 HTML 구조 가져오기
767        $.ajax({
768            type: 'POST',
769            data: {
770                'prod_idx': product_idx,
771                'product_name': product_info.name,
772                'product_price_text': product_info.price_text,
773                'product_thumb_url': product_info.thumb_url
774            },
775            url: '/shop/free_ship_notify/get_product_option_modal.cm',
776            dataType: 'html',
777            cache: false,
778            success: function(modalHtml) {
779
780                // 2단계: 옵션 HTML 가져오기 (context를 전달하여 cart 컨텍스트에서는 cart 핸들러가 emit 되도록 한다)
781                $.ajax({
782                    type: 'POST',
783                    data: {
784                        'prod_idx': product_idx,
785                        'selected_require_options': [],
786                        '__': '',
787                        'is_additional': true,
788                        'context': context
789                    },
790                    url: '/shop/load_prod_additional_option.cm',
791                    dataType: 'json',
792                    cache: false,
793                    success: function(optionResult) {
794
795                        if (optionResult.msg === 'SUCCESS') {
796                            if (typeof SITE_SHOP_DETAIL !== 'undefined' && typeof SITE_SHOP_DETAIL.setProdAdditionalRequireOptionCount === 'function') {
797                                SITE_SHOP_DETAIL.setProdAdditionalRequireOptionCount(
798                                    optionResult.require_option_count,
799                                    optionResult.require_input_option_count
800                                );
801                            }
802
803                            // 모달 HTML에 옵션 HTML 삽입
804                            var finalHtml = insertOptionHtml(modalHtml, optionResult.option_html);
805
806                            // cocoaDialog로 모달 표시 (cart 컨텍스트에서는 initDetail 등에 메타데이터를 전달)
807                            showProductOptionModal(finalHtml, product_idx, product_info, context, optionResult, callback);
808                        } else {
809                            if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
810                        }
811                    },
812                    error: function(xhr, status, error) {
813                        if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
814                    }
815                });
816            },
817            error: function(xhr, status, error) {
818                if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
819            }
820        });
821    }
822    
823    /**
824     * 모달 HTML에 옵션 HTML을 삽입
825     * @param {string} modalHtml - 기본 모달 HTML
826     * @param {string} optionHtml - 옵션 HTML
827     * @returns {string} 완성된 HTML
828     */
829    function insertOptionHtml(modalHtml, optionHtml) {
830        // 임시 div에 modalHtml을 로드해서 DOM 조작
831        var tempDiv = document.createElement('div');
832        tempDiv.innerHTML = modalHtml;
833        
834        // #prod_free_ship_notify_additional_options를 찾아서 innerHTML 설정
835        var optionContainer = tempDiv.querySelector('#prod_free_ship_notify_additional_options');
836        if (optionContainer) {
837            optionContainer.innerHTML = optionHtml;
838        }
839        
840        return tempDiv.innerHTML;
841    }
842    
843    // 모달을 띄운 마지막 맥락. 모달 HTML의 submit 버튼이 completeAdditionalProductSelection()을 인자 없이 호출하므로
844    // 어느 맥락에서 모달이 떴는지 기억해둔다.
845    var currentSelectionContext = 'prod';
846    // cart 맥락에서 submit 시 어느 상품을 카트에 담을지 기억 (SITE_SHOP_DETAIL.initDetail에 넘긴 prod_idx)
847    var currentSelectionProductIdx = 0;
848    // prod 맥락 completeAdditionalProductSelection 에서 cartItems:update dispatch 용 prod_code 추적
849    var currentSelectionProductCode = '';
850    var currentSelectionShippingTemplateCode = '';
851    var currentSelectionCartType = '';
852    // 현재 활성 모달의 cleanup/resolve 핸들. 각 showProductOptionModal 호출마다 closure로 생성되어
853    // 자신의 product_idx와 snapshot에 묶인다. complete/cancel/implicit close가 이 핸들을 통해
854    // 정확한 모달 인스턴스에 대해서만 동작하도록 보장.
855    var _currentModalCleanup = null;            // 명시적 cancel / 암시적 close에서 호출 — snapshot으로 restore
856    var _currentModalMarkResolved = null;       // 명시적 complete에서 호출 — 후속 implicit cleanup 차단
857
858    /**
859     * SITE_SHOP_DETAIL의 옵션 관리 기계를 cart 맥락에서 초기화한다.
860     * showChangeCartItem과 동일한 패턴 - load_prod_additional_option.cm 응답 메타데이터를 그대로 사용.
861     * @param {object} optionResult load_prod_additional_option.cm 응답
862     */
863    function initDetailForCartContext(optionResult) {
864        if (typeof SITE_SHOP_DETAIL === 'undefined') {
865            return;
866        }
867
868        if (window.innerWidth < 768
869            && typeof SITE_SHOP_DETAIL.resetSelectedRequireOptions === 'function') {
870            SITE_SHOP_DETAIL.resetSelectedRequireOptions();
871        }
872
873        if (typeof SITE_SHOP_DETAIL.initDetail === 'function') {
874            SITE_SHOP_DETAIL.initDetail({
875                prod_idx: optionResult.prod_idx,
876                prod_price: optionResult.prod_price,
877                require_option_count: optionResult.require_option_count_total || 0,
878                use_image_optimizer_on_product_detail_markup: false,
879                shop_use_full_load: false
880            });
881        }
882
883        if (typeof SITE_SHOP_DETAIL.initProdStock === 'function' && optionResult.prod_stock) {
884            SITE_SHOP_DETAIL.initProdStock(
885                optionResult.prod_stock.stock_use,
886                optionResult.prod_stock.stock_no_option,
887                optionResult.prod_stock.stock_unlimit
888            );
889        }
890
891        if (typeof SITE_SHOP_DETAIL.setMaxPurchaseQuantity === 'function' && optionResult.max_prod_quantity !== undefined) {
892            SITE_SHOP_DETAIL.setMaxPurchaseQuantity(
893                optionResult.max_prod_quantity,
894                optionResult.max_member_quantity,
895                optionResult.maximum_purchase_quantity_type,
896                optionResult.optional_limit,
897                optionResult.optional_limit_type,
898                optionResult.prod_name
899            );
900        }
901
902    }
903
904    /**
905     * cocoaDialog로 상품 옵션 모달 표시
906     * @param {string} html - 완성된 모달 HTML
907     * @param {number} product_idx - 상품 인덱스
908     * @param {object} product_info - 상품 정보 (get_additional_product_info.cm 응답)
909     * @param {'prod'|'cart'} context - 호출 맥락
910     * @param {object} optionResult - load_prod_additional_option.cm 응답 (cart 컨텍스트 초기화용 메타데이터 포함)
911     * @param {function} callback - 완료 콜백 함수
912     */
913    function showProductOptionModal(html, product_idx, product_info, context, optionResult, callback) {
914        try {
915            // 이 모달 인스턴스 전용 closure 변수들. 새 모달마다 독립 생성되어 hide_event/backdrop click이
916            // 자신의 product_idx와 진입 시점 snapshot에만 적용되도록 격리한다.
917            var modalContext = (context === 'cart') ? 'cart' : 'prod';
918            var modalProductIdx = product_idx;
919            var modalResolved = false;
920            // prod 맥락에서만 진입 직전 상태 snapshot을 받아둠. cart 맥락은 카트로 직접 담는 흐름이라 불필요.
921            var modalSnapshot = null;
922            if (modalContext === 'prod'
923                && typeof window.SITE_SHOP_DETAIL !== 'undefined'
924                && typeof window.SITE_SHOP_DETAIL.snapshotProdAdditionalForModal === 'function') {
925                modalSnapshot = window.SITE_SHOP_DETAIL.snapshotProdAdditionalForModal(modalProductIdx);
926            }
927
928            currentSelectionContext = modalContext;
929            currentSelectionProductIdx = modalProductIdx;
930            currentSelectionProductCode = (product_info && typeof product_info.code === 'string') ? product_info.code : '';
931            currentSelectionShippingTemplateCode = (product_info && typeof product_info.shipping_template_code === 'string') ? product_info.shipping_template_code : '';
932            currentSelectionCartType = (product_info && (
933                product_info.is_regularly_prod === true ||
934                product_info.is_regularly_prod === 'Y' ||
935                product_info.only_regularly === true ||
936                product_info.only_regularly === 'Y'
937            )) ? 'regularly' : '';
938
939            // 현재 모달의 cleanup 핸들 — snapshot 기반 restore. 진입 직전 상태로 되돌려
940            // "이번 모달이 추가한 만큼만" revert (기존에 담겨있던 옵션/항목은 보존).
941            var cleanupForThisModal = function () {
942                if (modalResolved) return;
943                modalResolved = true;
944                if (modalContext === 'prod' && modalProductIdx > 0
945                    && modalSnapshot
946                    && typeof window.SITE_SHOP_DETAIL !== 'undefined'
947                    && typeof window.SITE_SHOP_DETAIL.restoreProdAdditionalForModal === 'function') {
948                    window.SITE_SHOP_DETAIL.restoreProdAdditionalForModal(modalProductIdx, modalSnapshot);
949                }
950                if (modalProductIdx === currentSelectionProductIdx) {
951                    currentSelectionProductIdx = 0;
952                }
953            };
954            // 명시적 complete에서 호출 — restore 없이 resolved만 표시해 후속 implicit cleanup 차단.
955            var markResolvedForThisModal = function () {
956                modalResolved = true;
957            };
958            _currentModalCleanup = cleanupForThisModal;
959            _currentModalMarkResolved = markResolvedForThisModal;
960
961            // prod 맥락에서만 SITE_SHOP_DETAIL의 추가상품 모달 상태 초기화 수행.
962            // cart 맥락은 "추가상품 선택" 개념 없이 바로 카트에 담으므로 다른 초기화(initDetailForCartContext)를 사용한다.
963            if (currentSelectionContext === 'prod'
964                && typeof SITE_SHOP_DETAIL !== 'undefined'
965                && typeof SITE_SHOP_DETAIL.selectAdditionalProductForModal === 'function') {
966                if (typeof SITE_SHOP_DETAIL.setFreeShipNotifyProdAdditionalRegularPrice === 'function') {
967                    SITE_SHOP_DETAIL.setFreeShipNotifyProdAdditionalRegularPrice(product_idx, product_info.regular_price || product_info.price || 0);
968                }
969                SITE_SHOP_DETAIL.selectAdditionalProductForModal(
970                    product_idx,
971                    product_info.price || 0,
972                    product_info.name || '',
973                    product_info.maximum_purchase_quantity || 0,
974                    product_info.member_maximum_purchase_quantity || 0,
975                    product_info.maximum_purchase_quantity_type || '',
976                    product_info.optional_limit || 0,
977                    product_info.optional_limit_type || '',
978                    product_info.stock_use || 0,
979                    product_info.stock || 0,
980                    product_info.stock_unlimit || 0,
981                    product_info.option_mix_type || '',
982                    product_info.prod_type || '',
983                    product_info.period_discount_flag || 0,
984                    product_info.period_discount_data || {},
985                    product_info.period_discout_group_list || [],
986                    product_info.membership_discount || 0,
987                    product_info.additional_creator_discount_data || null
988                );
989            }
990
991            var $staleOptionModal = $('.modal_site_shop_free_ship_notify_option.in');
992            if ($staleOptionModal.length > 0) {
993                $staleOptionModal.modal('hide');
994            }
995
996            var is_mobile_width = (window.innerWidth < 768);
997            if(is_mobile_width){
998                // 추가 상품 시트와 역할을 공유하므로 ID를 같이 사용
999                imSheet.close('prod_additional_sheet', () => {
1000                    imSheet.open({
1001                        id: 'prod_additional_sheet',
1002                        html: html,
1003                        backdrop: 'rgba(0, 0, 0, 0.15)',
1004                        zIndex: 17001,
1005                        onShow: function() {
1006                            // cart 컨텍스트는 모달이 DOM에 들어간 뒤 SITE_SHOP_DETAIL 초기화
1007                            // (initDetail이 모달 안의 #prod_options 등을 찾아야 하므로 mount 이후에 호출)
1008                            if (currentSelectionContext === 'cart') {
1009                                initDetailForCartContext(optionResult);
1010                            }
1011                        }
1012                    });
1013
1014                    // backdrop 영역 클릭으로 닫을 때도 cleanup이 동작하도록 추가 click 핸들러 등록.
1015                    // closure로 캡쳐된 cleanupForThisModal을 직접 호출 — 자신의 product_idx와 snapshot 기준으로 안전하게 restore.
1016                    $('#prod_additional_sheet').on('click', function(e) {
1017                        if ($(e.target).parents('.im-sheet-content').length === 0) {
1018                            cleanupForThisModal();
1019                        }
1020                    });
1021
1022                    // MFE 추천 영역의 담기 버튼 로딩 해제가 이 콜백에 걸려 있으므로 PC 분기와 동일하게 호출
1023                    if (callback) callback(true, '상품 옵션 모달을 표시했습니다.');
1024                });
1025            }else{
1026                $.cocoaDialog.open({
1027                    type: 'site_shop_free_ship_notify_option',
1028                    custom_popup: html,
1029                    pc_width: 440,
1030                    // 외부 클릭/ESC 등으로 cocoaDialog가 닫힐 때도 cleanup이 동작하도록 hide_event 등록.
1031                    // closure로 캡쳐된 cleanupForThisModal — 자신의 product_idx와 snapshot 기준으로 안전하게 restore.
1032                    // 명시적 complete/cancel은 modalResolved=true로 표시하므로 중복 cleanup 안 됨.
1033                    hide_event: cleanupForThisModal
1034                });
1035
1036                // PC 모달은 동기 mount이므로 바로 초기화
1037                if (currentSelectionContext === 'cart') {
1038                    initDetailForCartContext(optionResult);
1039                }
1040
1041                if (callback) callback(true, '상품 옵션 모달을 표시했습니다.');
1042            }
1043        } catch (error) {
1044            if (callback) callback(false, getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
1045        }
1046    }
1047
1048    /**
1049     * 추가 상품 선택 취소 처리 및 모달 닫기.
1050     * 모달 HTML의 취소 버튼/X 버튼에서 호출. prod 맥락은 selectAdditionalProductForModal이
1051     * 모달 노출 시점에 이미 selected_prod_additionals에 push했으므로 명시적 cleanup이 필요하다.
1052     * cart 맥락은 다음 모달이 열릴 때 initDetail이 모든 상태를 reset하므로 별도 cleanup 불필요.
1053     */
1054    function cancelAdditionalProductSelection() {
1055        // 현재 활성 모달의 cleanup 호출 — snapshot 기반 restore로 진입 직전 상태로 복
1055원.
1056        // 이 호출이 modalResolved=true로 표시하므로 후속 hide_event / backdrop click이 중복 cleanup하지 않는다.
1057        if (typeof _currentModalCleanup === 'function') {
1058            _currentModalCleanup();
1059        }
1060
1061        var is_mobile_width = (window.innerWidth < 768);
1062        if (is_mobile_width) {
1063            if (typeof imSheet !== 'undefined' && typeof imSheet.close === 'function') {
1064                imSheet.close('prod_additional_sheet');
1065            }
1066        } else if (typeof $ !== 'undefined' && typeof $.cocoaDialog === 'object' && typeof $.cocoaDialog.close === 'function') {
1067            var $optionModal = $('.modal_site_shop_free_ship_notify_option.in');
1068            if ($optionModal.length > 0) {
1069                $optionModal.modal('hide');
1070            } else {
1071                $.cocoaDialog.close();
1072            }
1073        }
1074    }
1075
1076    /**
1077     * 추가 상품 선택 완료 처리 및 모달 닫기.
1078     * 모달 HTML의 submit 버튼에서 호출되며 인자를 받지 않는다. 직전 showProductOptionModal에서 저장한
1079     * currentSelectionContext를 읽어 context별로 분기한다.
1080     */
1081    function completeAdditionalProductSelection() {
1082        try {
1083            // 정상 완료 흐름 표시. 후속 hide_event / backdrop click이 snapshot 기준으로 되돌리지 않도록 resolved 플래그만 set.
1084            if (typeof _currentModalMarkResolved === 'function') {
1085                _currentModalMarkResolved();
1086            }
1087            if (currentSelectionContext === 'cart') {
1088                // cart 맥락: 무료배송 모달에서는 selectRequireOption이 selectOption을 호출하지 않고 선택 상태만 유지하므로
1089                // (selectProdAdditionalRequireOption 패턴과 동일) 여기서 selected_require_options를 직접 읽어 addCart 포맷으로 변환한다.
1090                if (typeof window.SITE_SHOP_CART === 'undefined' || typeof window.SITE_SHOP_CART.addCart !== 'function') {
1091                    return;
1092                }
1093                if (typeof SITE_SHOP_DETAIL === 'undefined' || typeof SITE_SHOP_DETAIL.getSelectedRequireOptions !== 'function') {
1094                    return;
1095                }
1096
1097                var requireOptions = SITE_SHOP_DETAIL.getSelectedRequireOptions();
1098                var requireCount = (typeof SITE_SHOP_DETAIL.getRequireOptionCount === 'function')
1099                    ? (SITE_SHOP_DETAIL.getRequireOptionCount() || 0)
1100                    : 0;
1101                if (!requireOptions || requireOptions.length < requireCount) {
1102                    alert(typeof LOCALIZE !== 'undefined' && typeof LOCALIZE.설명_필수옵션이모두선택되어있지않습니다 === 'function'
1103                        ? LOCALIZE.설명_필수옵션이모두선택되어있지않습니다()
1104                        : '필수 옵션을 모두 선택해주세요.');
1105                    return;
1106                }
1107
1108                var prodIdx = currentSelectionProductIdx;
1109
1110                $.ajax({
1111                    type: 'POST',
1112                    url: '/shop/select_option.cm',
1113                    data: {
1114                        'prod_idx': prodIdx,
1115                        'count': 1,
1116                        'require': 'Y',
1117                        'options': requireOptions
1118                    },
1119                    dataType: 'json',
1120                    cache: false,
1121                    success: function (selectOptionResult) {
1122                        if (!selectOptionResult || selectOptionResult.msg !== 'SUCCESS') {
1123                            alert((selectOptionResult && selectOptionResult.msg) || getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
1124                            return;
1125                        }
1126
1127                        var optionPrice = (selectOptionResult.selected_option && typeof selectOptionResult.selected_option.price === 'number')
1128                            ? selectOptionResult.selected_option.price
1129                            : 0;
1130
1131                        // add_cart.cm이 기대하는 포맷: [{ options, require, count, price }]
1132                        var addCartPayload = [{
1133                            options: requireOptions,
1134                            require: true,
1135                            count: 1,
1136                            price: optionPrice
1137                        }];
1138
1139                        window.SITE_SHOP_CART.addCart(prodIdx, addCartPayload, 1, function(success, message, addCartResult) {
1140                            if (success) {
1141                                dispatchAdditionalProductAdded(prodIdx, addCartResult);
1142                                var is_mobile_width = (window.innerWidth < 768);
1143                                if (is_mobile_width) {
1144                                    if (typeof SITE_SHOP_DETAIL !== 'undefined'
1145                                        && typeof SITE_SHOP_DETAIL.resetSelectedRequireOptions === 'function') {
1146                                        SITE_SHOP_DETAIL.resetSelectedRequireOptions();
1147                                    }
1148                                    currentSelectionProductIdx = 0;
1149                                    currentSelectionContext = 'prod';
1150                                    imSheet.close('prod_additional_sheet');
1151                                } else {
1152                                    $('.modal_site_shop_free_ship_notify_option.in').modal('hide');
1153                                }
1154                            } else {
1155                                alert(message || getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
1156                            }
1157                        }, {
1158                            shipping_template_code: currentSelectionShippingTemplateCode,
1159                            cart_type: currentSelectionCartType
1160                        });
1161                    },
1162                    error: function () {
1163                        alert(getLocalizeString('설명_오류가발생했습니다', '', '오류가 발생했습니다.'));
1164                    }
1165                });
1166                return;
1167            }
1168
1169            if (typeof window.SITE_SHOP_DETAIL === 'undefined') {
1170                return;
1171            }
1172
1173            // SITE_SHOP_DETAIL의 completeAdditionalProductSelection 함수 호출
1174            if (typeof window.SITE_SHOP_DETAIL.completeAdditionalProductSelection === 'function') {
1175                window.SITE_SHOP_DETAIL.completeAdditionalProductSelection();
1176                dispatchAdditionalProductAdded(currentSelectionProductIdx);
1177            }
1178
1179        } catch (error) {
1180        }
1181    }
1182
1183    // 모달의 close/continue 버튼 트래킹 — MFE 가 modal placement 마운트 시 setCloseTracker 로
1184    // 비동기 핸들러를 등록. PHP 의 close 버튼 onclick 이 trackCloseAndContinue(afterAction) 호출 시
1185    // 핸들러를 최대 500ms 기다린 후 afterAction 을 실행 (페이지 이동 race 최소화).
1186    var _closeTracker = null;
1187
1188    function setCloseTracker(tracker) {
1189        _closeTracker = typeof tracker === 'function' ? tracker : null;
1190    }
1191
1192    function trackCloseAndContinue(afterAction) {
1193        var done = false;
1194        function runAfter() {
1195            if (done) return;
1196            done = true;
1197            if (typeof afterAction !== 'function') return;
1198            try {
1199                afterAction();
1200            } catch (e) {
1201                // afterAction throw 가 Promise unhandled rejection 으로 묶이지 않도록 마이크로태스크 밖으로 재던짐.
1202                // 기존 동기 onclick 동작과 동등한 에러 surface 유지 (window.onerror 잡힘).
1203                setTimeout(function () { throw e; }, 0);
1204            }
1205        }
1206
1207        if (typeof _closeTracker !== 'function') {
1208            runAfter();
1209            return Promise.resolve();
1210        }
1211
1212        var trackerPromise;
1213        try {
1214            trackerPromise = _closeTracker();
1215        } catch (e) {
1216            runAfter();
1217            return Promise.resolve();
1218        }
1219        if (!trackerPromise || typeof trackerPromise.then !== 'function') {
1220            runAfter();
1221            return Promise.resolve();
1222        }
1223
1224        var timeoutPromise = new Promise(function (resolve) { setTimeout(resolve, 500); });
1225        return Promise.race([trackerPromise.catch(function () {}), timeoutPromise])
1226            .then(runAfter, runAfter);
1227    }
1228
1229    // 공개 API
1230    return {
1231        setAccentColor: setAccentColor,
1232        sendSelectedOptionsUpdate: sendSelectedOptionsUpdate,
1233        updateSelectedOptionsTotal: updateSelectedOptionsTotal,
1234        loadState: loadState,
1235        loadStateByCodes: loadStateByCodes,
1236        selectAdditionalProductByIdx: selectAdditionalProductByIdx,
1237        getProductOptionHtml: getProductOptionHtml,
1238        completeAdditionalProductSelection: completeAdditionalProductSelection,
1239        hasOrderAlarmShown: hasOrderAlarmShown,
1240        markOrderAlarmShown: markOrderAlarmShown,
1241        hasAddCartAlarmShown: hasAddCartAlarmShown,
1242        markAddCartAlarmShown: markAddCartAlarmShown,
1243        setRequireOptionCompleted: setRequireOptionCompleted,
1244        setCartFreeShippingRemainingPrice: setCartFreeShippingRemainingPrice,
1245        resetInlineMagnetItemCounts: resetInlineMagnetItemCounts,
1246        dispatchInlineMagnetItemCounts: dispatchInlineMagnetItemCounts,
1247        getInlineMagnetCodeByIdx: getInlineMagnetCodeByIdx,
1248        syncInlineMagnetItemCountsFromAdditionals: syncInlineMagnetItemCountsFromAdditionals,
1249        cancelAdditionalProductSelection: cancelAdditionalProductSelection,
1250        getCachedCartTotal: getCachedCartTotal,
1251        getCachedCartFreeShippingRemainingPrice: getCachedCartFreeShippingRemainingPrice,
1252        getCachedSelectedOptionsTotal: getCachedSelectedOptionsTotal,
1253        getCachedFreeShipNotifySettings: getCachedFreeShipNotifySettings,
1254        getCachedFreeShipNotifyConfig: getCachedFreeShipNotifyConfig,
1255        setCloseTracker: setCloseTracker,
1256        trackCloseAndContinue: trackCloseAndContinue
1257    };
1258
1259})();
1260
1261try {
1262    window.dispatchEvent(new CustomEvent('imweb:freeShipNotify:sdk-ready'));
1263} catch (e) {}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.