// 신규 원장님 혜택 안내 페이지 (PC) // 정적 마크업은 new-member.html 에 있고, 여기서는 카탈로그 집계·기획전 상품·앵커 내비만 채운다. (function () { const mount = document.getElementById('gbNmev'); if (!mount) return; const PRODUCT_COUNT = 16; const BRAND_ROWS = 2; // 어드민 기획전에 등록된 상품을 그대로 노출한다. // MD 가 어드민에서 기획전 상품만 갈아끼우면 이 영역이 즉시 바뀐다(스킨 수정 불필요). const PICK_EVENT_NO = 78154; // 브랜드 칩은 상품 수 순으로 한 번에 뽑으면 네일이 앞줄을 독차지한다. // 업종별 인기 브랜드를 번갈아 뽑아 카테고리가 고르게 섞이게 한다(줄리 2026-08-28). // 카테고리 축은 브랜드관(brand-hall.js)과 동일 기준 — 네일은 젤네일+네일아트&도구 합본. // 라이프스타일(923195)은 샵 운영 소모품이라 브랜드 축에서 뺀다 — 가전 브랜드가 칩에 올라온다(매트 2026-08-31). const BRAND_CATS = ['915138,915150', '915206', '915212', '915214', '915217', '915221']; // '전체' 상품도 같은 업종 축으로 섞는다. 네일은 기획전에서 오므로 여기선 뺀다. // 주요 업종(속눈썹·반영구·왁싱·피부관리)만 돌린다 — 헤어·소모품까지 넣으면 선풍기 같은 게 올라온다(줄리 2026-08-31). const MIX_CATS = ['915206', '915212', '915214', '915217']; // 업종별로 몇 번째까지 끌어올지 — 품절 제외로 목록이 줄어도 16칸이 차게 여유를 둔다. const MIX_DEPTH = 8; // '기타'는 브랜드가 아니라 브랜드 미지정 상품 묶음이라 목록에서 뺀다. const EXCLUDED_BRAND = '기타'; // 개발몰에는 카탈로그 데이터가 없어 화면 검증이 불가하므로, 개발에서만 운영몰 clientId 로 조회한다(브랜드관과 동일). const PROD_CLIENT_ID = '6lhBAvGVUqi3qFHXzJtjcA=='; const apiOptions = /shopby\.co\.kr$/.test(location.hostname) ? { clientId: PROD_CLIENT_ID } : undefined; const won = (n) => (n || 0).toLocaleString('ko-KR'); const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); // 실시간 집계 그대로 쓰면 숫자가 매번 흔들려서, 내림한 근사치 + ↑ 로 표기한다. const floorNice = (n) => { n = n || 0; if (n >= 1000) return Math.floor(n / 100) * 100; if (n >= 10) return Math.floor(n / 10) * 10; return n; }; const approx = (n) => `${won(floorNice(n))}개`; const fixImg = (u) => (u ? (u.indexOf('//') === 0 ? `https:${u}` : u) : ''); // 카탈로그가 상품명을 HTML 엔티티로 내려준다(자석&글리터젤) — 그대로 쓰면 화면에 & 가 보인다. const decode = (v) => String(v == null ? '' : v).replace( /&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]), ); // 품절·판매중지 상품은 라인업에서 뺀다(줄리 2026-08-31). // 카탈로그 검색은 기본이 품절 제외지만, 기획전은 상태와 무관하게 등록 상품을 내려주므로 여기서 거른다. const onSale = (it) => !!it && !it.isSoldOut && it.saleStatusType === 'ONSALE'; // 상품명 앞 말머리 중 브랜드명과 같은 것만 뗀다 — 브랜드를 따로 표기하므로 중복이다. const stripBrand = (name, brand) => { const lead = brand && name.match(/^\s*(?:\[[^\]]*\]\s*)+/); if (!lead) return name; const groups = lead[0].match(/\[[^\]]*\]/g) || []; const kept = groups.filter((g) => g.slice(1, -1).trim() !== brand); if (kept.length === groups.length) return name; return (kept.length ? `${kept.join('')} ` : '') + name.slice(lead[0].length); }; // 고정 헤더(띠배너 포함) 높이만큼 sticky 내비를 내려 앉힌다. const topOffset = () => { let h = 0; ['#gb-signup-band', '.shopby-header > div', '.header', 'header', '.category-slider'].forEach((s) => { const e = document.querySelector(s); if (!e) return; const cs = getComputedStyle(e); if (cs.position === 'fixed' && cs.display !== 'none') h = Math.max(h, e.getBoundingClientRect().bottom); }); return Math.max(0, Math.round(h)); }; const applyTopOffset = () => { const top = topOffset(); mount.style.setProperty('--nmev-top', `${top}px`); document.documentElement.style.setProperty('--nmev-top', `${top}px`); return top; }; let TOP = applyTopOffset(); // 띠배너는 헤더 partial 이 비동기로 심어서 첫 계산에 안 잡힐 수 있다. window.addEventListener('load', () => { TOP = applyTopOffset(); }); // 앵커 내비 const navBtns = Array.prototype.slice.call(mount.querySelectorAll('.nmev-nav button')); navBtns.forEach((b) => { b.addEventListener('click', () => { const el = document.getElementById(b.dataset.go); if (!el) return; const navH = mount.querySelector('.nmev-nav').getBoundingClientRect().height; window.scrollTo({ top: window.pageYOffset + el.getBoundingClientRect().top - TOP - navH, behavior: 'smooth' }); }); }); const secIds = navBtns.map((b) => b.dataset.go); window.addEventListener( 'scroll', () => { let cur = 0; secIds.forEach((id, i) => { const el = document.getElementById(id); if (el && el.getBoundingClientRect().top - TOP - 80 <= 0) cur = i; }); navBtns.forEach((b, i) => b.classList.toggle('is-on', i === cur)); }, { passive: true }, ); // 상품 스켈레톤 const brandsBox = mount.querySelector('#nmevBrands'); const box = mount.querySelector('#nmevProds'); const SKELETON = new Array(PRODUCT_COUNT) .fill( '
  •   
  • ', ) .join(''); const showSkeleton = () => { box.classList.add('nmev-prods--loading'); box.innerHTML = SKELETON; }; const card = (it) => { const img = fixImg((it.listImageUrls || it.imageUrls || [])[0]); const brand = decode(it.brandName || ''); const name = stripBrand(decode(it.productName || ''), brand); return ( '
  • ' + `` + `${brand && brand !== EXCLUDED_BRAND ? esc(brand) : ' '}` + `${esc(name)}` + '
  • ' ); }; const renderProds = (items) => { box.classList.remove('nmev-prods--loading'); box.innerHTML = items.map(card).join(''); box.scrollLeft = 0; }; showSkeleton(); if (typeof window.fetchShopApi !== 'function') return; const api = (path) => window.fetchShopApi(path, apiOptions); // 브랜드 칩을 누르면 아래 상품 영역이 그 브랜드 상품으로 바뀐다. '전체'는 기본 목록으로 되돌린다. let allItems = []; let activeBrand = ''; const brandCache = {}; const selectBrand = (brandNo) => { activeBrand = brandNo || ''; if (!activeBrand) { if (allItems.length) renderProds(allItems); return; } if (brandCache[activeBrand]) { renderProds(brandCache[activeBrand]); return; } showSkeleton(); api( `/products/search?brandNos=${activeBrand}&order.by=POPULAR&order.direction=DESC&filter.soldout=false&pageSize=${PRODUCT_COUNT}`, ) .then((r) => { const items = (r.items || []).filter(onSale).slice(0, PRODUCT_COUNT); if (!items.length) throw new Error('empty'); brandCache[brandNo] = items; if (activeBrand === String(brandNo)) renderProds(items); }) .catch(() => renderProds(allItems)); }; brandsBox.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; Array.prototype.forEach.call(brandsBox.querySelectorAll('button'), (b) => b.classList.toggle('is-on', b === btn)); selectBrand(btn.dataset.brand); }); // 칩이 여러 줄로 흐르면 영역이 과하게 길어져서, 정해진 줄 수까지만 남기고 잘라낸다. // 폭이 폰트 로드 뒤에 확정되므로 웹폰트가 준비된 다음 잰다. const trimRows = (ul, rows) => { const more = ul.lastElementChild; const rowCount = () => { const tops = []; Array.prototype.forEach.call(ul.children, (li) => { const t = Math.round(li.offsetTop); if (tops.indexOf(t) < 0) tops.push(t); }); return tops.length; }; while (ul.children.length > 2 && rowCount() > rows) ul.removeChild(more.previousElementSibling); }; // 브랜드 칩 — 업종별 인기 브랜드를 번갈아 뽑아 카테고리가 고르게 섞이게 한다. Promise.all( BRAND_CATS.map((nos) => api(`/products/search/summary?categoryNos=${nos}&categoryOperator=OR`) .then((s) => (s.brands || []) .filter((b) => b.brandName && b.brandName !== EXCLUDED_BRAND) .sort((a, b) => b.count - a.count), ) .catch(() => []), ), ).then((lists) => { // 여러 업종에 걸친 브랜드는 먼저 뽑힌 자리에만 한 번 노출한다. const picks = []; const seen = {}; const depth = Math.max.apply(null, lists.map((l) => l.length).concat(0)); for (let i = 0; i < depth; i += 1) { lists.forEach((l) => { const b = l[i]; if (!b || seen[b.brandName]) return; seen[b.brandName] = true; picks.push(b); }); } if (!picks.length) { brandsBox.remove(); return; } brandsBox.innerHTML = '
  • ' + picks.map((b) => `
  • `).join('') + '
  • 전체 브랜드 보기
  • '; const trim = () => trimRows(brandsBox, BRAND_ROWS); if (document.fonts && document.fonts.ready) document.fonts.ready.then(trim, trim); else trim(); }); // 라인업 지표 — 실제 카탈로그 집계에서 가져온다. api('/products/search/summary') .then((s) => { const brands = (s.brands || []).filter((b) => b.brandName !== '기타'); const cats = (s.depth1Categories || []).filter((c) => (c.label || '').indexOf('최저가') < 0); const stats = mount.querySelectorAll('#nmevStats strong'); stats[0].innerHTML = approx(brands.length); stats[1].innerHTML = approx(s.totalCount); stats[2].textContent = `${won(cats.length)}개`; const guard = (s.depth1Categories || []).filter((c) => (c.label || '').indexOf('최저가') >= 0)[0]; if (guard) mount.querySelector('#nmevGuardNum').innerHTML = approx(guard.count); }) .catch(() => {}); // '전체' 목록 — 기획전 상품만 쓰면 네일만 깔려서 네일 전문몰처럼 보인다. // 기획전(78154)은 그대로 두고 업종별 인기 상품을 번갈아 끼워 카테고리가 고르게 보이게 한다. Promise.all( [ api(`/display/events/${PICK_EVENT_NO}`) .then((j) => ((j && j.section && j.section[0] && j.section[0].products) || []).filter(onSale)) .catch(() => []), ].concat( MIX_CATS.map((nos) => api( `/products/search?categoryNos=${nos}&categoryOperator=OR&order.by=POPULAR&order.direction=DESC&filter.soldout=false&pageSize=${MIX_DEPTH}`, ) .then((r) => (r.items || []).filter(onSale)) .catch(() => []), ), ), ).then((lists) => { const picked = []; const seen = {}; const depth = Math.max.apply(null, lists.map((l) => l.length).concat(0)); for (let i = 0; i < depth && picked.length < PRODUCT_COUNT; i += 1) { for (let k = 0; k < lists.length && picked.length < PRODUCT_COUNT; k += 1) { const it = lists[k][i]; if (!it || seen[it.productNo]) continue; seen[it.productNo] = true; picked.push(it); } } allItems = picked; // 기획전·카탈로그가 모두 비면 빈 회색 박스가 남는 것보다 영역을 감추는 게 낫다. if (!picked.length) { if (!activeBrand) box.remove(); return; } if (!activeBrand) renderProds(picked); }); })();