// 신규 원장님 혜택 안내 페이지 (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);
});
})();