feat: 업체 링크 관리 화면과 아임웹 추적 스크립트 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled

- 관리자 화면 '업체 링크' 메뉴: 업체 추가, 링크 발급·복사·중지, 기간별 클릭·신청·구매,
  링크별 실적 회원 목록, 상품 불러오기·구매 집계 새로고침
- partner-ref.html (Header Code): 클릭 ID 를 30일 보관하고 로그인 회원과 잇는다
- imweb-free-apply.html: 신청 기록(클릭 ID 포함)과 오픈카톡 클릭 기록 전송 추가
- README 에 귀속 규칙·한계·처음 켤 때 순서 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 23:47:10 +09:00
parent 7bef853664
commit 59c84dcac5
4 changed files with 1338 additions and 6 deletions
+525 -2
View File
@@ -212,6 +212,11 @@
#mg-members th { font-size: 12px; color: var(--mgm-muted); font-weight: 600; background: #fafbfc; position: sticky; top: 0; }
#mg-members td.mgm-wrap { white-space: normal; min-width: 140px; }
#mg-members .mgm-empty { padding: 32px; text-align: center; color: var(--mgm-muted); }
#mg-members tr.is-selected td { background: var(--mgm-accent-soft); }
/* 업체 링크 */
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
#mg-members .mgm-url { display: inline-block; max-width: 280px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
/* 연동 관리 */
#mg-members .mgm-dl { display: grid; grid-template-columns: 140px 1fr; gap: 10px 16px; margin: 0; }
@@ -292,6 +297,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
신청 내역
</button>
<button type="button" data-view="partners">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21v-6h6v6"/><path d="M3 21h18"/></svg>
업체 링크
</button>
<button type="button" data-view="integration">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
연동 관리
@@ -519,6 +528,119 @@
</section>
</section>
<!-- 업체 링크 -->
<section data-panel="partners" hidden>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-filter-top">
<div class="mgm-seg" role="group" aria-label="실적 집계 기간">
<button type="button" data-ptn-range="all" class="is-active">전체</button>
<button type="button" data-ptn-range="today">오늘</button>
<button type="button" data-ptn-range="7">7일</button>
<button type="button" data-ptn-range="30">30일</button>
<button type="button" data-ptn-range="month">이번 달</button>
</div>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="ptn-order-sync">구매 집계 상태 확인 중</span>
<button type="button" data-action="ptn-order-sync">구매 집계 새로고침</button>
<button type="button" data-action="ptn-product-sync">상품 불러오기</button>
</div>
<p class="mgm-muted" style="margin:10px 0 0">
링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
구매는 결제완료 주문만 세며(환불·취소 제외) 아임웹 주문을 1시간마다 확인합니다.
</p>
</section>
<div class="mgm-grid-2" style="margin-bottom:16px">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>업체</h3>
<span class="mgm-muted" data-role="ptn-partner-total"></span>
</div>
<form data-role="ptn-partner-form" class="mgm-row" style="margin-bottom:12px" autocomplete="off">
<input name="partnerName" maxlength="100" required placeholder="업체 이름" aria-label="업체 이름" style="flex:1 1 140px;width:auto">
<input name="memo" maxlength="1000" placeholder="메모 (선택 — 담당자·정산 조건 등)" aria-label="업체 메모" style="flex:2 1 200px;width:auto">
<button type="submit" class="mgm-primary">업체 추가</button>
</form>
<div class="mgm-table-wrap">
<table>
<thead><tr><th>업체</th><th>링크</th><th>클릭</th><th>신청</th><th>구매</th><th></th></tr></thead>
<tbody data-role="ptn-partner-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-partner-empty" hidden>등록된 업체가 없습니다. 위에서 업체를 추가해 주세요.</div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>링크 발급</h3>
<span class="mgm-muted">업체·상품 조합당 하나</span>
</div>
<form data-role="ptn-link-form" style="display:flex;flex-direction:column;gap:10px" autocomplete="off">
<label class="mgm-field">업체
<select name="partnerId" required><option value="">업체를 먼저 추가해 주세요</option></select>
</label>
<label class="mgm-field">상품
<select name="prodNo" required><option value="">'상품 불러오기'를 눌러 주세요</option></select>
</label>
<label class="mgm-field">이동할 상품 페이지 주소
<input name="targetUrl" type="url" required maxlength="2000" placeholder="https://www.moneyguider.co.kr/free-shop/?idx=52">
</label>
<span class="mgm-muted">아임웹에서 상품 페이지를 열고 주소창의 주소를 붙여 넣으세요. 우리 사이트 안의 주소만 됩니다.</span>
<div class="mgm-row"><button type="submit" class="mgm-primary">링크 발급</button></div>
</form>
</section>
</div>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>링크</h3>
<span data-role="ptn-link-filter" class="mgm-row" style="gap:6px"></span>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="ptn-link-total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr>
<th>업체</th><th>상품</th><th>업체에 줄 링크</th><th>클릭</th><th>신청</th><th>구매</th>
<th>상태</th><th>발급일</th><th></th>
</tr>
</thead>
<tbody data-role="ptn-link-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-link-empty" hidden>발급한 링크가 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="ptn-link-more" hidden>더 보기</button>
</div>
</section>
<section class="mgm-card" data-role="ptn-conv-card" hidden>
<div class="mgm-card-head">
<h3>실적 상세</h3>
<span class="mgm-muted" data-role="ptn-conv-title"></span>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="실적 종류">
<button type="button" data-ptn-conv-type="" class="is-active">전체</button>
<button type="button" data-ptn-conv-type="application">신청</button>
<button type="button" data-ptn-conv-type="purchase">구매</button>
</div>
<button type="button" class="mgm-link" data-action="ptn-conv-close">닫기</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr><th>신청·주문 일시</th><th>종류</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th><th>주문번호</th><th>링크 클릭</th></tr>
</thead>
<tbody data-role="ptn-conv-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-conv-empty" hidden>아직 실적이 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="ptn-conv-more" hidden>더 보기</button>
</div>
</section>
</section>
<!-- 연동 관리 -->
<section data-panel="integration" hidden>
<section class="mgm-card" style="margin-bottom:16px">
@@ -563,6 +685,7 @@
overview: { title: '개요', desc: '회원 수와 유입 현황을 한눈에 봅니다' },
members: { title: '회원 목록', desc: '가입 기간·유입경로로 회원을 찾습니다' },
applications: { title: '신청 내역', desc: '누가 어떤 강의에 신청했는지 봅니다 (신청 완료 화면 기준)' },
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 셉니다' },
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
};
@@ -577,6 +700,12 @@
cursor: null,
listSeq: 0,
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
ptn: {
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
partners: [], products: null, syncTimer: null,
conv: { linkId: '', label: '', type: '', cursor: null, loading: false, seq: 0 }
},
suggestLoaded: false,
suggestions: {},
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
@@ -792,6 +921,7 @@
if (view === 'overview') loadOverview();
else if (view === 'members') loadMembers(true);
else if (view === 'applications') { state.app.productsStale = true; loadApplications(true); }
else if (view === 'partners') loadPartnersView();
else if (view === 'integration') renderIntegration();
}
@@ -1415,6 +1545,399 @@
loadApplications(true);
});
/* ===================== 업체 링크 ===================== */
var ptnPanel = $('[data-panel="partners"]');
var ptnPartnerForm = $('[data-role="ptn-partner-form"]');
var ptnLinkForm = $('[data-role="ptn-link-form"]');
var PTN_LIST_LIMIT = 100; // 업체·상품 선택 목록은 한 번에 받는다 (API 최대값)
var PTN_PRODUCT_STATUS = { sale: '판매중', soldout: '품절', nosale: '숨김' };
var PTN_SYNC_HINT = {
IMWEB_006: '주문 읽기 권한이 없습니다 — 연동 관리에서 아임웹 연결을 다시 해 주세요',
IMWEB_001: '아임웹이 연결되어 있지 않습니다'
};
var PTN_CONV_TYPE = {
application: '<span class="mgm-badge is-ok">신청</span>',
purchase: '<span class="mgm-badge is-warn">구매</span>'
};
function ptnPeriod() {
var f = rangeQuery(state.ptn.range), out = {};
if (f.joinedFrom) out.from = f.joinedFrom;
if (f.joinedTo) out.to = f.joinedTo;
return out;
}
function statCells(s) {
return '<td>' + num(s.clickCount) + '</td><td>' + num(s.applicationCount) + '</td><td>' + num(s.purchaseCount) + '</td>';
}
function findPartner(id) {
return state.ptn.partners.filter(function (p) { return p.id === id; })[0] || null;
}
function loadPartnersView() {
loadPartners();
loadPtnProducts(false);
loadLinks(true);
loadOrderSyncStatus();
}
// 업체 목록 + 링크 발급 폼의 업체 선택
function loadPartners() {
return api('GET', '/admin/partners' + toQuery(Object.assign({ limit: PTN_LIST_LIMIT }, ptnPeriod())))
.then(function (page) {
state.ptn.partners = page.items;
renderPartners();
renderPartnerSelect();
renderLinkFilter();
}).catch(errorNotice);
}
function renderPartners() {
var list = state.ptn.partners;
$('[data-role="ptn-partner-rows"]').innerHTML = list.map(function (p) {
return '<tr' + (p.id === state.ptn.partnerId ? ' class="is-selected"' : '') + '>' +
'<td class="mgm-wrap"><button type="button" class="mgm-link" data-ptn-partner="' + esc(p.id) +
'" title="이 업체의 링크만 보기">' + esc(p.name) + '</button>' +
(p.memo ? '<br><span class="mgm-muted">' + esc(p.memo) + '</span>' : '') + '</td>' +
'<td>' + num(p.linkCount) + '</td>' + statCells(p.stats) +
'<td><button type="button" class="mgm-link" data-ptn-edit-partner="' + esc(p.id) + '">수정</button>' +
(p.linkCount === 0
? '<button type="button" class="mgm-link" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>'
: '') + '</td>' +
'</tr>';
}).join('');
$('[data-role="ptn-partner-empty"]').hidden = list.length > 0;
$('[data-role="ptn-partner-total"]').textContent = list.length ? num(list.length) + '곳' : '';
}
function renderPartnerSelect() {
var sel = ptnLinkForm.partnerId, current = sel.value || state.ptn.partnerId;
var list = state.ptn.partners;
sel.innerHTML = list.length
? '<option value="">업체 선택</option>' + list.map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + '</option>';
}).join('')
: '<option value="">업체를 먼저 추가해 주세요</option>';
sel.value = current; // 없는 값이면 빈 값으로 돌아간다
}
function loadPtnProducts(force) {
if (state.ptn.products && !force) { renderProductSelect(); return Promise.resolve(); }
return api('GET', '/admin/products' + toQuery({ limit: PTN_LIST_LIMIT })).then(function (page) {
state.ptn.products = page.items;
renderProductSelect();
}).catch(errorNotice);
}
function renderProductSelect() {
var sel = ptnLinkForm.prodNo, current = sel.value;
var list = state.ptn.products || [];
sel.innerHTML = list.length
? '<option value="">상품 선택</option>' + list.map(function (p) {
var status = p.prodStatus !== 'sale' && PTN_PRODUCT_STATUS[p.prodStatus];
return '<option value="' + esc(p.prodNo) + '">' + esc(p.name) + ' #' + esc(p.prodNo) +
(status ? ' (' + esc(status) + ')' : '') + '</option>';
}).join('')
: '<option value="">\'상품 불러오기\'를 눌러 주세요</option>';
sel.value = current;
}
function renderLinkFilter() {
var p = findPartner(state.ptn.partnerId);
$('[data-role="ptn-link-filter"]').innerHTML = p
? '<span class="mgm-active-chip"><b>업체</b> ' + esc(p.name) +
'<button type="button" data-action="ptn-clear-partner" aria-label="업체 필터 지우기">×</button></span>'
: '';
}
function linkRowHtml(l) {
var label = l.partner.name + ' · ' + (l.product.name || '#' + l.product.prodNo);
return '<tr' + (l.id === state.ptn.conv.linkId ? ' class="is-selected"' : '') + ' data-link-row="' + esc(l.id) + '">' +
'<td>' + esc(l.partner.name) + '</td>' +
'<td class="mgm-wrap">' + (l.product.name ? esc(l.product.name) : '<span class="mgm-muted">아임웹에서 지운 상품</span>') +
' <span class="mgm-muted">#' + esc(l.product.prodNo) + '</span></td>' +
'<td><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
'<button type="button" class="mgm-link" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button>' +
'<br><span class="mgm-muted mgm-url" title="' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span></td>' +
statCells(l.stats) +
'<td>' + (l.isActive ? '<span class="mgm-badge is-ok">사용</span>' : '<span class="mgm-badge">중지</span>') + '</td>' +
'<td>' + esc(kstDateTime(l.createdAt).slice(0, 10)) + '</td>' +
'<td>' +
'<button type="button" class="mgm-link" data-ptn-conv="' + esc(l.id) + '" data-label="' + esc(label) + '">실적</button>' +
'<button type="button" class="mgm-link" data-ptn-edit-link="' + esc(l.id) + '" data-url="' + esc(l.targetUrl) + '">주소 변경</button>' +
'<button type="button" class="mgm-link" data-ptn-toggle-link="' + esc(l.id) + '" data-active="' + (l.isActive ? '1' : '') + '">' +
(l.isActive ? '중지' : '다시 사용') + '</button>' +
'</td>' +
'</tr>';
}
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다 (회원 목록과 같은 방식)
function loadLinks(reset) {
var ptn = state.ptn;
if (!reset && ptn.loading) return;
var seq = ++ptn.seq;
ptn.loading = true;
if (reset) ptn.cursor = null;
var q = Object.assign({ limit: PAGE_SIZE }, ptnPeriod());
if (ptn.partnerId) q.partnerId = ptn.partnerId;
if (ptn.cursor) q.cursor = ptn.cursor;
var moreBtn = $('[data-action="ptn-link-more"]');
moreBtn.disabled = true;
api('GET', '/admin/partner-links' + toQuery(q)).then(function (page) {
if (seq !== ptn.seq) return;
var tbody = $('[data-role="ptn-link-rows"]');
var html = page.items.map(linkRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
ptn.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="ptn-link-empty"]').hidden = tbody.children.length > 0;
var total = page.pageInfo.total;
$('[data-role="ptn-link-total"]').textContent = total === null ? '' : '총 ' + num(total) + '개';
}).catch(function (err) {
if (seq === ptn.seq) errorNotice(err);
}).then(function () {
if (seq !== ptn.seq) return;
ptn.loading = false;
moreBtn.disabled = false;
});
}
function convRowHtml(c) {
var m = c.member;
var idCell = m
? esc(m.uid) + (m.email && m.email !== m.uid ? '<br><span class="mgm-muted">' + esc(m.email) + '</span>' : '')
: esc(c.memberCode) + '<br><span class="mgm-muted">회원 정보 없음 (탈퇴 등)</span>';
return '<tr>' +
'<td>' + esc(kstDateTime(c.convertedAt)) + '</td>' +
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') + '</td>' +
'<td>' + dash(m && m.name) + '</td>' +
'<td>' + idCell + '</td>' +
'<td>' + dash(m && m.callnum) + '</td>' +
'<td>' + dash(c.orderNo) + '</td>' +
'<td>' + esc(kstDateTime(c.touchedAt)) + '</td>' +
'</tr>';
}
function markSelectedLink() {
each($all('[data-link-row]'), function (tr) {
tr.classList.toggle('is-selected', tr.getAttribute('data-link-row') === state.ptn.conv.linkId);
});
}
function openConversions(linkId, label) {
var conv = state.ptn.conv;
conv.linkId = linkId;
conv.type = '';
each($all('[data-ptn-conv-type]'), function (x) {
x.classList.toggle('is-active', x.getAttribute('data-ptn-conv-type') === '');
});
$('[data-role="ptn-conv-title"]').textContent = label + ' · 전체 기간';
var card = $('[data-role="ptn-conv-card"]');
card.hidden = false;
markSelectedLink();
loadConversions(true);
if (card.scrollIntoView) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function loadConversions(reset) {
var conv = state.ptn.conv;
if (!conv.linkId || (!reset && conv.loading)) return;
var seq = ++conv.seq;
conv.loading = true;
if (reset) conv.cursor = null;
var q = { limit: PAGE_SIZE };
if (conv.type) q.type = conv.type;
if (conv.cursor) q.cursor = conv.cursor;
var moreBtn = $('[data-action="ptn-conv-more"]');
moreBtn.disabled = true;
api('GET', '/admin/partner-links/' + encodeURIComponent(conv.linkId) + '/conversions' + toQuery(q)).then(function (page) {
if (seq !== conv.seq) return;
var tbody = $('[data-role="ptn-conv-rows"]');
var html = page.items.map(convRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
conv.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="ptn-conv-empty"]').hidden = tbody.children.length > 0;
}).catch(function (err) {
if (seq === conv.seq) errorNotice(err);
}).then(function () {
if (seq !== conv.seq) return;
conv.loading = false;
moreBtn.disabled = false;
});
}
function renderOrderSync(s) {
var el = $('[data-role="ptn-order-sync"]');
if (s.isRunning) { el.textContent = '구매 집계 중…'; return; }
if (s.lastErrorCode) {
el.innerHTML = '<span class="mgm-badge is-danger">' + esc(s.lastErrorCode) + '</span> ' +
esc(PTN_SYNC_HINT[s.lastErrorCode] || '구매 집계에 실패했습니다');
return;
}
el.textContent = s.lastFinishedAt
? '구매 집계 ' + kstDateTime(s.lastFinishedAt) + ' · 주문 ' + num(s.checkedOrderCount) + '건 확인'
: '구매 집계 대기 중 (1시간마다 자동)';
}
function loadOrderSyncStatus() {
return api('GET', '/admin/partner-order-sync-runs/latest').then(function (s) {
renderOrderSync(s);
return s;
}).catch(function (err) { errorNotice(err); return null; });
}
// 새로고침이 끝날 때까지 상태를 확인하고, 끝나면 숫자를 다시 불러온다
function pollOrderSync() {
clearTimeout(state.ptn.syncTimer);
state.ptn.syncTimer = setTimeout(function () {
loadOrderSyncStatus().then(function (s) {
if (s && s.isRunning) { pollOrderSync(); return; }
$('[data-action="ptn-order-sync"]').disabled = false;
if (s && !s.lastErrorCode) notice('구매 집계를 마쳤습니다. 새로 잡힌 구매 ' + num(s.createdCount) + '건', 'ok');
if (state.view === 'partners') {
loadPartners();
loadLinks(true);
loadConversions(true);
}
});
}, SYNC_POLL_MS);
}
function copyText(text) {
if (!navigator.clipboard || !navigator.clipboard.writeText) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return false; });
}
each($all('[data-ptn-range]'), function (b) {
b.addEventListener('click', function () {
state.ptn.range = b.getAttribute('data-ptn-range');
each($all('[data-ptn-range]'), function (x) { x.classList.toggle('is-active', x === b); });
loadPartners();
loadLinks(true);
});
});
ptnPartnerForm.addEventListener('submit', function (e) {
e.preventDefault();
// form.name 은 폼 자신의 name 속성이라 입력란 이름을 partnerName 으로 둔다
var name = ptnPartnerForm.partnerName.value.trim();
if (!name) return;
var body = { name: name };
var memo = ptnPartnerForm.memo.value.trim();
if (memo) body.memo = memo;
api('POST', '/admin/partners', body).then(function (p) {
ptnPartnerForm.reset();
notice('업체 "' + esc(p.name) + '"을(를) 추가했습니다. 오른쪽에서 링크를 발급하세요.', 'ok');
ptnLinkForm.partnerId.value = '';
return loadPartners().then(function () { ptnLinkForm.partnerId.value = p.id; });
}).catch(errorNotice);
});
ptnLinkForm.addEventListener('submit', function (e) {
e.preventDefault();
var body = {
partnerId: ptnLinkForm.partnerId.value,
prodNo: Number(ptnLinkForm.prodNo.value),
targetUrl: ptnLinkForm.targetUrl.value.trim()
};
if (!body.partnerId || !body.prodNo || !body.targetUrl) return;
api('POST', '/admin/partner-links', body).then(function (l) {
ptnLinkForm.targetUrl.value = '';
notice('링크를 발급했습니다. 업체에 이 주소를 전달하세요: <b>' + esc(l.shortUrl) + '</b>', 'ok');
loadPartners();
loadLinks(true);
}).catch(errorNotice);
});
ptnPanel.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('button') : null;
if (!t || !ptnPanel.contains(t)) return;
var action = t.getAttribute('data-action');
var id;
if (action === 'ptn-product-sync') {
t.disabled = true;
api('POST', '/admin/products/sync-runs').then(function (r) {
notice('아임웹 상품 ' + num(r.fetchedCount) + '개를 불러왔습니다.', 'ok');
return loadPtnProducts(true);
}).catch(errorNotice).then(function () { t.disabled = false; });
} else if (action === 'ptn-order-sync') {
t.disabled = true;
api('POST', '/admin/partner-order-sync-runs').then(function (s) {
renderOrderSync(s);
pollOrderSync();
}).catch(function (err) { t.disabled = false; errorNotice(err); });
} else if (action === 'ptn-link-more') {
loadLinks(false);
} else if (action === 'ptn-conv-more') {
loadConversions(false);
} else if (action === 'ptn-conv-close') {
state.ptn.conv.linkId = '';
$('[data-role="ptn-conv-card"]').hidden = true;
markSelectedLink();
} else if (action === 'ptn-clear-partner') {
state.ptn.partnerId = '';
renderPartners();
renderLinkFilter();
loadLinks(true);
} else if ((id = t.getAttribute('data-ptn-partner'))) {
// 같은 업체를 다시 누르면 필터를 푼다
state.ptn.partnerId = state.ptn.partnerId === id ? '' : id;
if (state.ptn.partnerId) ptnLinkForm.partnerId.value = id;
renderPartners();
renderLinkFilter();
loadLinks(true);
} else if ((id = t.getAttribute('data-ptn-edit-partner'))) {
var partner = findPartner(id);
if (!partner) return;
var newName = window.prompt('업체 이름', partner.name);
if (newName === null || !newName.trim()) return;
var newMemo = window.prompt('메모 (비우면 지웁니다)', partner.memo || '');
if (newMemo === null) return;
api('PATCH', '/admin/partners/' + encodeURIComponent(id), { name: newName.trim(), memo: newMemo.trim() })
.then(function () { loadPartners(); loadLinks(true); })
.catch(errorNotice);
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
var target = findPartner(id);
if (!target || !window.confirm('업체 "' + target.name + '"을(를) 삭제할까요?')) return;
api('DELETE', '/admin/partners/' + encodeURIComponent(id)).then(function () {
if (state.ptn.partnerId === id) state.ptn.partnerId = '';
loadPartners();
}).catch(errorNotice);
} else if (t.hasAttribute('data-ptn-copy')) {
var url = t.getAttribute('data-ptn-copy');
copyText(url).then(function (ok) {
notice(ok ? '링크를 복사했습니다: <b>' + esc(url) + '</b>'
: '복사하지 못했습니다. 직접 선택해 복사해 주세요: <b>' + esc(url) + '</b>', ok ? 'ok' : 'error');
});
} else if ((id = t.getAttribute('data-ptn-conv'))) {
openConversions(id, t.getAttribute('data-label') || '');
} else if ((id = t.getAttribute('data-ptn-edit-link'))) {
var nextUrl = window.prompt('이동할 상품 페이지 주소', t.getAttribute('data-url') || '');
if (nextUrl === null || !nextUrl.trim()) return;
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { targetUrl: nextUrl.trim() })
.then(function () { notice('이동 주소를 바꿨습니다. 업체에 준 링크는 그대로 쓰면 됩니다.', 'ok'); loadLinks(true); })
.catch(errorNotice);
} else if ((id = t.getAttribute('data-ptn-toggle-link'))) {
var isActive = t.getAttribute('data-active') === '1';
if (isActive && !window.confirm('이 링크를 중지할까요?\n중지해도 상품 페이지로 이동은 되지만, 클릭·실적은 더 이상 쌓이지 않습니다.')) return;
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { isActive: !isActive })
.then(function () { loadLinks(true); })
.catch(errorNotice);
} else if (t.hasAttribute('data-ptn-conv-type')) {
state.ptn.conv.type = t.getAttribute('data-ptn-conv-type');
each($all('[data-ptn-conv-type]'), function (x) { x.classList.toggle('is-active', x === t); });
loadConversions(true);
}
});
/* ===================== 회원 목록 ===================== */
var STATUS_BADGE = {
at_join: '<span class="mgm-badge is-ok">가입 시</span>',
@@ -1497,8 +2020,8 @@
if (!result) return;
// 아임웹이 동의 단계에서 거부한 경우의 코드 — 개발자센터 설정으로 고칠 수 있는 것들
var IMWEB_HINT = {
'30156': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기(site-info:write)"를 켜 주세요.',
'30099': '개발자센터 앱 권한에 "회원 정보 읽기(member-info:read)"와 "사이트 정보 읽기∙쓰기"를 켜 주세요.',
'30156': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
'30099': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
'30098': '개발자센터 앱의 리다이렉트 URI 를 확인해 주세요.'
};
if (result === 'connected') notice('아임웹이 연결되었습니다. 첫 동기화를 시작합니다.', 'ok');