generated from ttipo/project-base
feat: 업체 링크 관리 화면과 아임웹 추적 스크립트 추가
- 관리자 화면 '업체 링크' 메뉴: 업체 추가, 링크 발급·복사·중지, 기간별 클릭·신청·구매, 링크별 실적 회원 목록, 상품 불러오기·구매 집계 새로고침 - 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:
+525
-2
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user