(() => { const { EventManager, utils } = ShopbySkin; const { isMobileDevice } = utils; const headerEl = document.querySelector('shopby-header-v2'); // 마이 메뉴 로그인 상태 깜빡임 방지: 동기 판정으로 클래스를 즉시 심어 // 비동기 헤더 재렌더로 '마이페이지/주문목록' ↔ 'login' 이 순간 뒤바뀌어 보이지 않게 함. const syncAuthStateClass = () => { try { const authed = utils.isSignedIn(); document.documentElement.classList.toggle('gongbiz-authed', authed); document.documentElement.classList.toggle('gongbiz-guest', !authed); } catch (e) { // 판정 실패 시 클래스 미부여(둘 다 숨김) — 잘못된 상태 노출보다 안전. } }; syncAuthStateClass(); // 회원가입 유도 띠배너(비로그인 전용). 노출/오프셋은 custom-common.css 가 담당하고 // 여기서는 마크업 주입과 "이번 방문(세션) 동안 닫기"만 처리한다. // sessionStorage 라 탭·창을 닫으면 초기화돼 다음 방문에 다시 노출된다. const SIGNUP_BAND_DISMISS_KEY = 'gongbiz-signup-band-dismissed'; const isSignupBandDismissed = () => { try { return sessionStorage.getItem(SIGNUP_BAND_DISMISS_KEY) === '1'; } catch (e) { return false; } }; const renderSignupBand = () => { if (document.getElementById('gb-signup-band')) return; document.documentElement.classList.toggle('gb-band-hidden', isSignupBandDismissed()); const band = document.createElement('div'); band.id = 'gb-signup-band'; band.innerHTML = ` 회원가입 하고 10% 할인쿠폰 받기 › `; document.body.insertBefore(band, document.body.firstChild); band.querySelector('.gb-band__close').addEventListener('click', () => { try { sessionStorage.setItem(SIGNUP_BAND_DISMISS_KEY, '1'); } catch (e) { // 저장 실패(사생활 보호 모드 등) 시에는 이번 페이지에서만 숨긴다. } document.documentElement.classList.add('gb-band-hidden'); }); }; renderSignupBand(); // 외부스크립트 등록 기능. 삭제금지 window.ShopbyExternalScript?.initialize({ apiOption: { platform: 'PC', }, }).then(() => { EventManager.fire('QUERY_PAGE_SCRIPTS', window.ShopbyExternalScript?.scripts); }); async function fetchCouponSummary() { try { // ShopbySkin.actions 내의 관련 메서드를 호출합니다. // 보통 프로모션/쿠폰 관련은 promotion 관련 액션에 포함되어 있습니다. const response = await ShopbySkin.actions.fetchProfileCouponSummary(); const couponData = response.data; // 2. 데이터가 있고 사용 가능한 쿠폰이 0개보다 많을 때 (혹은 0개라도 표시하고 싶을 때) if (couponData && typeof couponData.usableCouponCnt !== 'undefined') { const usableCouponCnt = couponData.usableCouponCnt; // 3. 값 집어넣기 const cntElement = document.getElementById('dbook_coupon_cnt'); if (cntElement) { cntElement.innerText = usableCouponCnt; } } } catch (error) { // console.error('쿠폰 정보를 가져오는데 실패했습니다:', error); } } // ── 공비서스토어 소셜(카카오/네이버) 로그아웃 / 탈퇴메뉴 노출 ────────────── const PROD_HOSTS = ['gongbizstore.com', 'www.gongbizstore.com', 'm.gongbizstore.com', 'gongstore.shopby.co.kr']; const DEV_HOSTS = ['gongbizstore.shopby.co.kr', 'm-gongbizstore.shopby.co.kr']; const KEYCLOAK_REALM = 'gongbiz'; const KEYCLOAK_CLIENT_ID = 'cafe24'; const SIGN_IN_URL = '/pages/sign-in/sign-in.html'; const KEYCLOAK_ID_TOKEN_STORAGE_KEY = 'gongbiz-store-keycloak-id-token'; const KEYCLOAK_ACCESS_TOKEN_STORAGE_KEY = 'gongbiz-store-keycloak-access-token'; // 헤더 파셜은 모든 페이지에서 동작하지만 gongbiz-sso-env.js 는 sign-in/callback 에만 로드되므로, // GongbizSsoEnv 가 있으면 우선 사용하고 없으면 동일 기준(PROD_HOSTS)으로 폴백한다. const getKeycloakBaseUrl = () => { if (window.GongbizSsoEnv) return window.GongbizSsoEnv.baseUrl; const isProdHost = PROD_HOSTS.includes(window.location.hostname); const isDevHost = DEV_HOSTS.includes(window.location.hostname); return isDevHost || !isProdHost ? 'https://dev-sso.gongbiz.kr' : 'https://sso.gongbiz.kr'; }; const getKeycloakIdTokenHint = () => { try { return localStorage.getItem(KEYCLOAK_ID_TOKEN_STORAGE_KEY); } catch (e) { return null; } }; const clearKeycloakTokenHints = () => { try { localStorage.removeItem(KEYCLOAK_ID_TOKEN_STORAGE_KEY); localStorage.removeItem(KEYCLOAK_ACCESS_TOKEN_STORAGE_KEY); } catch (e) { // 저장소 접근 실패는 로그아웃을 막지 않는다. } }; const buildKeycloakLogoutUrl = () => { const url = new URL(`${getKeycloakBaseUrl()}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout`); const idTokenHint = getKeycloakIdTokenHint(); url.searchParams.set('client_id', KEYCLOAK_CLIENT_ID); url.searchParams.set('post_logout_redirect_uri', `${window.location.origin}${SIGN_IN_URL}`); if (idTokenHint) { url.searchParams.set('id_token_hint', idTokenHint); } return url.toString(); }; const signOutWithKeycloak = () => { const redirect = buildKeycloakLogoutUrl(); clearKeycloakTokenHints(); utils.signOut({ redirect, replace: true, }); }; const isKakaoOrNaverProvider = (value) => { const provider = String(value || '').trim().toLowerCase(); return ['kakao', 'naver', '카카오', '네이버'].includes(provider); }; const getKeycloakIdTokenPayload = () => { try { const token = getKeycloakIdTokenHint(); const payload = token?.split('.')[1]; if (!payload) return {}; const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/'); const paddedPayload = normalizedPayload.padEnd(Math.ceil(normalizedPayload.length / 4) * 4, '='); const decodedPayload = new TextDecoder().decode(Uint8Array.from(atob(paddedPayload), (char) => char.charCodeAt(0))); return JSON.parse(decodedPayload); } catch (e) { return {}; } }; const hasValidSocialKeycloakToken = () => { const payload = getKeycloakIdTokenPayload(); return isKakaoOrNaverProvider(payload.provider); }; const shouldShowWithdrawalMenu = (profile) => { const safeProfile = profile || {}; const providerCandidates = [ safeProfile.openIdProvider, safeProfile.openIdProviderLabel, safeProfile.provider, safeProfile.providerType, safeProfile.identityProvider, ]; return providerCandidates.some(isKakaoOrNaverProvider) || hasValidSocialKeycloakToken(); }; const updateWithdrawalMenuVisibility = (profile) => { const shouldShow = shouldShowWithdrawalMenu(profile); document.querySelectorAll('[data-gongbiz-social-withdrawal-menu]').forEach((menuEl) => { menuEl.hidden = !shouldShow; }); }; // 클릭이벤트 맵 // 1. SIGN_OUT : 로그아웃 const headerActionMap = { SIGN_OUT: () => { signOutWithKeycloak(); }, GO_MY_MENU: (e) => { if (!utils.isSignedIn()) { e.preventDefault(); const from = e.target.href; const nextUrl = `${location.origin}/pages/sign-in/sign-in.html`; location.href = `${nextUrl}?from=${encodeURIComponent(from)}`; } }, }; // 클릭이벤트 핸들러 const clickEventListener = (e) => { const action = e?.target?.closest('[shopby-action]')?.getAttribute('shopby-action'); headerActionMap[action]?.(e); }; // 상단 카테고리 slider 초기화 EventManager.on('PAGE_LOAD_COMPLETED', ({ profile }) => { syncAuthStateClass(); EventManager.fire('INIT_CATEGORY_SLIDER', { direction: 'horizontal', effect: 'slide', slidesPerView: 'auto', spaceBetween: 20, }); updateWithdrawalMenuVisibility(profile); // 외부스크립트 등록 기능. 삭제금지 window.ShopbyExternalScript?.setGlobalObjectSb({ getPlatform: () => (isMobileDevice ? 'MOBILE_WEB' : 'PC'), profile, }); // 로그인 상태에서만 쿠폰 요약 호출 (비로그인 시 불필요한 API 호출 방지) if (utils.isSignedIn()) { fetchCouponSummary(); } }); headerEl?.addEventListener('click', clickEventListener); // 헤더 밖(마이페이지 좌측 메뉴 등)의 로그아웃도 같은 Keycloak 로그아웃을 타야 // SSO 세션이 남아 재로그인 시 자동 로그인되는 일이 없다. document.addEventListener('click', (e) => { const signOutEl = e.target?.closest?.('[data-gongbiz-sign-out]'); if (!signOutEl) return; e.preventDefault(); signOutWithKeycloak(); }); const searchParams = new URLSearchParams(location.search); const trackingKey = searchParams.get('trackingKey'); const channelType = searchParams.get('channelType'); const { setTrackingKey, setChannelType } = ShopbySkin.utils; setTrackingKey(trackingKey); setChannelType(channelType); /* dbook.fe 추가 */ // 탑배너 document.addEventListener("DOMContentLoaded", function () { var closeBtn = document.querySelector("#topLine .top-btn-close"); if (closeBtn) { closeBtn.addEventListener("click", function () { var topLine = document.getElementById("topLine"); if (topLine) { topLine.style.height = "0"; } }); } }); /* 퀵메뉴 */ const quickIcon = document.querySelector('.quick-icon'); const quickMenuList = document.querySelector('.quick-menu-list'); if (quickIcon && quickMenuList) { quickIcon.addEventListener('click', function () { const isVisible = quickMenuList.style.display === 'flex'; if (isVisible) { quickMenuList.style.display = 'none'; quickIcon.classList.remove('on'); } else { quickMenuList.style.display = 'flex'; quickIcon.classList.add('on'); } }); } // PC 검색창 클릭/포커스 시 최근검색어 드롭다운 노출 (모바일과 동일 UX). // PC 헤더에는 저장을 담당하던 shopby recent-keyword 컴포넌트가 없으므로 // 검색 실행(버튼 클릭/Enter) 시점에 직접 localStorage.RECENT_KEYWORD_SET 에 저장하고 렌더한다. // 상품리스트 등 헤더가 비동기로 렌더되는 페이지에선 이 스크립트 실행 시점에 // .top-search-box 가 아직 없어 직접 바인딩이 누락됐다 → document 위임으로 바인딩한다. { const RECENT_KEY = 'RECENT_KEYWORD_SET'; const RECENT_MAX = 10; const getBox = () => document.querySelector('.top-search-box'); const getDropdown = () => getBox()?.querySelector('.header__recent'); const readKeywords = () => { try { const arr = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); return Array.isArray(arr) ? arr.filter((k) => typeof k === 'string') : []; } catch (e) { return []; } }; const writeKeywords = (arr) => { try { localStorage.setItem(RECENT_KEY, JSON.stringify(arr)); } catch (e) { /* 저장 실패 무시 */ } }; const saveKeyword = (kw) => { const word = (kw || '').trim(); if (!word) return; const next = [word, ...readKeywords().filter((k) => k !== word)].slice(0, RECENT_MAX); writeKeywords(next); }; const goSearch = (kw) => { saveKeyword(kw); location.href = '/pages/product/product-list.html?keywords=' + encodeURIComponent(kw); }; const render = () => { const dropdown = getDropdown(); if (!dropdown) return; const listEl = dropdown.querySelector('.header__recent-list'); const emptyEl = dropdown.querySelector('.header__recent-empty'); if (!listEl || !emptyEl) return; const words = readKeywords(); listEl.textContent = ''; if (!words.length) { emptyEl.hidden = false; listEl.hidden = true; return; } emptyEl.hidden = true; listEl.hidden = false; words.forEach((kw) => { const li = document.createElement('li'); li.className = 'recent-keyword__item'; const word = document.createElement('button'); word.type = 'button'; word.className = 'recent-keyword__word'; word.textContent = kw; // XSS 방지: textContent 사용 word.addEventListener('click', () => goSearch(kw)); const del = document.createElement('button'); del.type = 'button'; del.className = 'header__recent-del'; del.setAttribute('aria-label', '검색어 삭제'); del.textContent = '×'; del.addEventListener('click', (e) => { e.stopPropagation(); writeKeywords(readKeywords().filter((k) => k !== kw)); render(); }); li.appendChild(word); li.appendChild(del); listEl.appendChild(li); }); }; // 열려 있을 때는 재렌더하지 않는다. focusin 은 mousedown 시점에 발생하는데, // 드롭다운 내부 버튼(삭제 ×/검색어)을 누르면 그 버튼이 포커스를 가져가며 focusin 이 // 재발화 → render() 가 리스트를 다시 그려 눌린 버튼 노드를 교체하면 click 이 끝까지 // 도달하지 못한다. 그래서 이미 열려 있으면 렌더를 건너뛴다. const openRecent = () => { const dropdown = getDropdown(); if (!dropdown) return; if (dropdown.hidden) render(); dropdown.hidden = false; }; const closeRecent = () => { const dropdown = getDropdown(); if (dropdown) dropdown.hidden = true; }; const inSearchBox = (t) => t && t.closest && t.closest('.top-search-box'); document.addEventListener('focusin', (e) => { if (inSearchBox(e.target)) openRecent(); }); document.addEventListener('click', (e) => { if (!inSearchBox(e.target)) { closeRecent(); return; } if (e.target.closest('.header__recent-clear')) { writeKeywords([]); render(); return; } if (e.target.closest('.header__search-btn')) { // 검색 실행 시 입력값을 최근검색어로 저장 (shopby SEARCH 액션과 함께 동작) const inp = getBox()?.querySelector('.header__search-input'); if (inp) saveKeyword(inp.value); return; } if (e.target.closest('.header__search')) openRecent(); }); // 검색 결과 페이지에서 검색어를 지우면(빈 값) 최근검색어 드롭다운을 다시 노출한다. document.addEventListener('input', (e) => { const inp = e.target.closest && e.target.closest('.header__search-input'); if (inp && !inp.value.trim()) openRecent(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeRecent(); return; } const inp = e.target.closest && e.target.closest('.header__search-input'); if (inp && e.key === 'Enter') saveKeyword(inp.value); }); } initHeaderShrink(); /** * 스크롤을 내리면 헤더를 압축(136.6px → 99px)한다. * 스페이서(.shopby-header)는 유지하므로 콘텐츠가 튀지 않는다. * 임계값을 내릴 때 60 / 올릴 때 20 으로 벌려 경계에서 깜빡이는 것을 막는다. */ function initHeaderShrink() { const SHRINK_ON = 60; const SHRINK_OFF = 20; const root = document.documentElement; let ticking = false; const sync = () => { ticking = false; const y = window.scrollY || window.pageYOffset || 0; if (!root.classList.contains('hdr-shrink')) { if (y > SHRINK_ON) root.classList.add('hdr-shrink'); } else if (y < SHRINK_OFF) { root.classList.remove('hdr-shrink'); } }; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(sync); }, { passive: true }); sync(); } })();