diff --git a/imweb/README.md b/imweb/README.md
index 457358d..687dd92 100644
--- a/imweb/README.md
+++ b/imweb/README.md
@@ -28,8 +28,10 @@
2. 아임웹 개발자센터 앱 설정
- redirect URI: `http://localhost:3100/api/imweb/oauth/callback`
(= 루트 `BACKEND_API_URL` + `/imweb/oauth/callback`, 정확히 일치해야 한다)
- - 권한: `사이트 정보 읽기∙쓰기(site-info:write)`, `회원 정보 읽기(member-info:read)`
- (site-info:write 는 아임웹이 연동 때 필수로 요구한다 — 없으면 errorCode 30156)
+ - 권한: `사이트 정보 읽기∙쓰기(site-info:write)`, `회원 정보 읽기(member-info:read)`,
+ `상품 읽기(product:read)`, `주문 읽기(order:read)`
+ (백엔드가 이 네 가지를 요청한다 — 앱 설정에 하나라도 없으면 연결이 errorCode 30156 으로 거부된다.
+ site-info:write 는 아임웹이 연동 때 필수로 요구한다)
3. 백엔드 기동·마이그레이션
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
@@ -60,10 +62,10 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
-아임웹 `/apply` 페이지의 신청 완료 위젯(`money/product-free-complete.html`)이 표시될 때
+아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
-- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`)
+- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -72,6 +74,44 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
+## 업체 링크 (업체별 신청·구매 추적)
+
+상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
+관리자 화면 **업체 링크** 메뉴에서 업체 추가 → 링크 발급 → 실적 확인.
+
+```
+업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
+ │ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
+ ▼
+상품 페이지 ── partner-ref.html(Header Code)이 클릭 ID 를 30일 보관하고 주소창에서 지운다
+ │ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
+ ▼
+신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
+구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
+```
+
+- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
+- **구매 수**: 결제완료 주문만 센다. 전액 환불되거나 해당 상품 품목이 모두 취소·반품되면 빠진다. 금액은 관리하지 않는다
+- **링크는 지우지 않고 중지**한다 — 중지해도 상품 페이지로 이동은 되지만 클릭·실적은 쌓이지 않는다.
+ 링크가 있는 업체는 삭제할 수 없다 (실적 기록 보존)
+- 이동 주소는 아임웹 사이트(www 유무 무관) 안만 허용한다 — 단축 링크가 피싱에 쓰이지 않게.
+ 사이트 주소는 `IMWEB_SITE_URL`, 비우면 `IMWEB_ADMIN_PAGE_URL` 의 origin
+- 한계
+ - 링크를 누른 브라우저와 신청·구매한 브라우저가 다르면(휴대폰 → PC) 추적되지 않는다
+ - 비회원 주문은 회원과 이을 수 없어 세지 않는다
+ - 공개 페이지에서 오는 기록이라 위조를 완전히 막을 수는 없다 (실제 아임웹 회원·실제 클릭인지만 확인)
+ - 60일보다 오래된 구매의 환불은 반영되지 않는다
+
+### 처음 켤 때
+
+1. 아임웹 개발자센터 앱 권한에 `상품 읽기`·`주문 읽기` 추가 (위 '로컬에서 띄우기' 2번)
+2. 백엔드 배포 (마이그레이션 자동 실행)
+3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다.
+ 다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다
+4. `partner-ref.html` 을 아임웹 **Header Code** 에 붙여 넣는다 (사이트 전체)
+5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
+6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
+
## 인증 방식 — 왜 쿠키가 아닌가
백엔드(`moneyguide.wandergo.net`)와 관리자 화면(`www.moneyguider.co.kr`)이 **다른 사이트**라
@@ -106,6 +146,8 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- 아임웹 개발자센터 redirect URI 를 `https://moneyguide.wandergo.net/api/imweb/oauth/callback` 으로 등록
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
+- 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다
+ (`partner-ref.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
## 유입 기록 상태
diff --git a/imweb/imweb-free-apply.html b/imweb/imweb-free-apply.html
new file mode 100644
index 0000000..40d99ee
--- /dev/null
+++ b/imweb/imweb-free-apply.html
@@ -0,0 +1,653 @@
+
+
+
+
+
+
+
+
신청이 완료되었습니다.
+
+
+
+
+
+ 신청강의
+ [무료한정 😳] 하루 2시간 월1000 버는 AI 효자 상품 찾기
+
+
+
+
+ 무료 라이브 단톡방에 입장하셔서
+ 전자책, 무료강의 등 다양한 혜택 을 받아가세요!
+
+
+
+
+
+
+ 오픈카톡 입장하기
+
+
+
메인 페이지로 이동
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/imweb/imweb-members.html b/imweb/imweb-members.html
index a72ed2c..ed625ad 100644
--- a/imweb/imweb-members.html
+++ b/imweb/imweb-members.html
@@ -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 @@
신청 내역
+
+
+ 업체 링크
+
연동 관리
@@ -519,6 +528,119 @@
+
+
+
+
+
+ 전체
+ 오늘
+ 7일
+ 30일
+ 이번 달
+
+
+
구매 집계 상태 확인 중
+
구매 집계 새로고침
+
상품 불러오기
+
+
+ 링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
+ 구매는 결제완료 주문만 세며(환불·취소 제외) 아임웹 주문을 1시간마다 확인합니다.
+
+
+
+
+
+
+
업체
+
+
+
+
+ 등록된 업체가 없습니다. 위에서 업체를 추가해 주세요.
+
+
+
+
링크 발급
+ 업체·상품 조합당 하나
+
+
+
+
+
+
+
+
링크
+
+
+
+
+
+
+
+
+ 업체 상품 업체에 줄 링크 클릭 신청 구매
+ 상태 발급일
+
+
+
+
+
+ 발급한 링크가 없습니다.
+
+ 더 보기
+
+
+
+
+
+
실적 상세
+
+
+
+ 전체
+ 신청
+ 구매
+
+
닫기
+
+
+
+
+ 신청·주문 일시 종류 이름 아이디 / 이메일 연락처 주문번호 링크 클릭
+
+
+
+
+ 아직 실적이 없습니다.
+
+ 더 보기
+
+
+
+
@@ -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: '신청 ',
+ purchase: '구매 '
+ };
+
+ 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 '' + num(s.clickCount) + ' ' + num(s.applicationCount) + ' ' + num(s.purchaseCount) + ' ';
+ }
+ 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 '' +
+ '' + esc(p.name) + ' ' +
+ (p.memo ? '' + esc(p.memo) + ' ' : '') + ' ' +
+ '' + num(p.linkCount) + ' ' + statCells(p.stats) +
+ '수정 ' +
+ (p.linkCount === 0
+ ? '삭제 '
+ : '') + ' ' +
+ ' ';
+ }).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
+ ? '업체 선택 ' + list.map(function (p) {
+ return '' + esc(p.name) + ' ';
+ }).join('')
+ : '업체를 먼저 추가해 주세요 ';
+ 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
+ ? '상품 선택 ' + list.map(function (p) {
+ var status = p.prodStatus !== 'sale' && PTN_PRODUCT_STATUS[p.prodStatus];
+ return '' + esc(p.name) + ' #' + esc(p.prodNo) +
+ (status ? ' (' + esc(status) + ')' : '') + ' ';
+ }).join('')
+ : '\'상품 불러오기\'를 눌러 주세요 ';
+ sel.value = current;
+ }
+
+ function renderLinkFilter() {
+ var p = findPartner(state.ptn.partnerId);
+ $('[data-role="ptn-link-filter"]').innerHTML = p
+ ? '업체 ' + esc(p.name) +
+ '× '
+ : '';
+ }
+
+ function linkRowHtml(l) {
+ var label = l.partner.name + ' · ' + (l.product.name || '#' + l.product.prodNo);
+ return '' +
+ '' + esc(l.partner.name) + ' ' +
+ '' + (l.product.name ? esc(l.product.name) : '아임웹에서 지운 상품 ') +
+ ' #' + esc(l.product.prodNo) + ' ' +
+ '' + esc(l.shortUrl) + ' ' +
+ '복사 ' +
+ '→ ' + esc(l.targetUrl) + ' ' +
+ statCells(l.stats) +
+ '' + (l.isActive ? '사용 ' : '중지 ') + ' ' +
+ '' + esc(kstDateTime(l.createdAt).slice(0, 10)) + ' ' +
+ '' +
+ '실적 ' +
+ '주소 변경 ' +
+ '' +
+ (l.isActive ? '중지' : '다시 사용') + ' ' +
+ ' ' +
+ ' ';
+ }
+
+ // 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다 (회원 목록과 같은 방식)
+ 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 ? '' + esc(m.email) + ' ' : '')
+ : esc(c.memberCode) + '회원 정보 없음 (탈퇴 등) ';
+ return '' +
+ '' + esc(kstDateTime(c.convertedAt)) + ' ' +
+ '' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
+ (c.isValid ? '' : ' 환불·취소 ') + ' ' +
+ '' + dash(m && m.name) + ' ' +
+ '' + idCell + ' ' +
+ '' + dash(m && m.callnum) + ' ' +
+ '' + dash(c.orderNo) + ' ' +
+ '' + esc(kstDateTime(c.touchedAt)) + ' ' +
+ ' ';
+ }
+
+ 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 = '' + esc(s.lastErrorCode) + ' ' +
+ 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('링크를 발급했습니다. 업체에 이 주소를 전달하세요: ' + esc(l.shortUrl) + ' ', '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 ? '링크를 복사했습니다: ' + esc(url) + ' '
+ : '복사하지 못했습니다. 직접 선택해 복사해 주세요: ' + esc(url) + ' ', 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: '가입 시 ',
@@ -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');
diff --git a/imweb/partner-ref.html b/imweb/partner-ref.html
new file mode 100644
index 0000000..7f9c9f3
--- /dev/null
+++ b/imweb/partner-ref.html
@@ -0,0 +1,114 @@
+
+
+
+