(() => {
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();
}
})();