generated from ttipo/project-base
feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점 브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면 신청을 일으킨 경로가 가려졌다. - member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일) - 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄 - GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계 - 신청 목록에 inflowBasis·inflowField·inflowValue 필터 - 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터), 신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-1
@@ -77,7 +77,18 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
|
||||
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek().value` — peek() 는 객체를 돌려준다), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
|
||||
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
|
||||
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
|
||||
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| --- | --- | --- |
|
||||
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
|
||||
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
|
||||
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
|
||||
|
||||
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
|
||||
@@ -446,6 +446,26 @@
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/* 신청 직전의 최종 유입 — MG_UTM.peek().last 객체를 위와 같은 파이프 형식으로 바꾼다.
|
||||
최초 유입(위 value)은 2일간 처음 들어온 경로라, 그 사이 다른 경로로 다시 와서
|
||||
신청하면 신청을 일으킨 경로는 last 에만 있다. 없으면 빈 값 (서버가 최초 유입으로 채운다) */
|
||||
var LAST_KEYS = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts'];
|
||||
function readLastInflow() {
|
||||
try {
|
||||
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
|
||||
? window.MG_UTM.peek() : null;
|
||||
var last = peeked && typeof peeked === 'object' ? peeked.last : null;
|
||||
if (!last || typeof last !== 'object' || !last.src) return '';
|
||||
var parts = [];
|
||||
for (var i = 0; i < LAST_KEYS.length; i++) {
|
||||
var v = last[LAST_KEYS[i]];
|
||||
/* UTM 스크립트가 이미 | = 를 지운 값이지만 한 번 더 막는다 */
|
||||
if (v) parts.push(LAST_KEYS[i] + '=' + String(v).replace(/[|=]/g, ' '));
|
||||
}
|
||||
return parts.join('|').slice(0, INFLOW_MAX);
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/* imweb-header-top.html(업체 링크 추적)이 보관한 클릭 ID — 없으면 빈 배열 */
|
||||
function readPartnerRefs() {
|
||||
try {
|
||||
@@ -465,6 +485,8 @@
|
||||
if (name) body.productName = name.slice(0, PRODUCT_NAME_MAX);
|
||||
var inflow = readInflow();
|
||||
if (inflow) body.inflow = inflow;
|
||||
var lastInflow = readLastInflow();
|
||||
if (lastInflow) body.lastInflow = lastInflow;
|
||||
var refs = readPartnerRefs();
|
||||
if (refs.length) body.partnerRefs = refs;
|
||||
|
||||
|
||||
+103
-4
@@ -505,9 +505,30 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입 집계</h3>
|
||||
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
|
||||
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
|
||||
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
|
||||
</div>
|
||||
<div class="mgm-seg" role="group" aria-label="집계 항목">
|
||||
<button type="button" data-app-group="source" class="is-active">유입원</button>
|
||||
<button type="button" data-app-group="medium">매체</button>
|
||||
<button type="button" data-app-group="campaign">캠페인</button>
|
||||
<button type="button" data-app-group="content">콘텐츠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청</h3>
|
||||
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="app-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
@@ -515,7 +536,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
|
||||
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
<th>오픈카톡</th><th title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
@@ -699,7 +720,11 @@
|
||||
view: 'overview',
|
||||
cursor: null,
|
||||
listSeq: 0,
|
||||
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
|
||||
app: {
|
||||
range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true,
|
||||
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
|
||||
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
|
||||
},
|
||||
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
|
||||
ptn: {
|
||||
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
|
||||
@@ -1421,9 +1446,52 @@
|
||||
var q = appForm.q.value.trim();
|
||||
if (q) out.q = q;
|
||||
if (state.app.product) out.productIdx = state.app.product;
|
||||
var inf = state.app.inflowFilter;
|
||||
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
|
||||
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
|
||||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||||
var APP_STATS_TOP = 12;
|
||||
|
||||
function loadAppStats() {
|
||||
var app = state.app;
|
||||
var seq = ++app.statsSeq;
|
||||
var q = { basis: app.basis, groupBy: app.group };
|
||||
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
|
||||
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
|
||||
if (f.appliedTo) q.appliedTo = f.appliedTo;
|
||||
if (f.productIdx) q.productIdx = f.productIdx;
|
||||
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
|
||||
if (seq !== app.statsSeq) return;
|
||||
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
|
||||
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
|
||||
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
|
||||
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
|
||||
}
|
||||
|
||||
function renderAppInflowFilter() {
|
||||
var inf = state.app.inflowFilter;
|
||||
$('[data-role="app-inflow-filter"]').innerHTML = inf
|
||||
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
|
||||
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
|
||||
: '';
|
||||
}
|
||||
|
||||
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
|
||||
function applyInflowCell(a) {
|
||||
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
|
||||
var html = inflowText(last);
|
||||
if (first && first.source && last && last.source &&
|
||||
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
|
||||
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
|
||||
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function productLabel(p) {
|
||||
return (p.productName || '상품') + ' #' + p.productIdx;
|
||||
}
|
||||
@@ -1473,7 +1541,7 @@
|
||||
'<td>' + (a.kakaoClickedAt
|
||||
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">눌렀음</span>'
|
||||
: '<span class="mgm-muted">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
|
||||
'<td class="mgm-wrap">' + applyInflowCell(a) + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(m && m.inflow) + '</td>' +
|
||||
'<td class="mgm-group-cell">' + (m && m.groups.length
|
||||
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
|
||||
@@ -1487,7 +1555,7 @@
|
||||
if (!reset && app.loading) return;
|
||||
var seq = ++app.seq;
|
||||
app.loading = true;
|
||||
if (reset) { app.cursor = null; loadAppProducts(); }
|
||||
if (reset) { app.cursor = null; loadAppProducts(); loadAppStats(); renderAppInflowFilter(); }
|
||||
|
||||
var q = appFilters();
|
||||
q.limit = PAGE_SIZE;
|
||||
@@ -1534,6 +1602,37 @@
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.basis = b.getAttribute('data-app-basis');
|
||||
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
each($all('[data-app-group]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.group = b.getAttribute('data-app-group');
|
||||
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
|
||||
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
|
||||
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
|
||||
if (!bar || bar.disabled) return;
|
||||
var value = bar.getAttribute('data-key');
|
||||
var cur = state.app.inflowFilter;
|
||||
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
|
||||
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
|
||||
loadApplications(true);
|
||||
});
|
||||
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
|
||||
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
|
||||
state.app.inflowFilter = null;
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
var appSearchTimer = null;
|
||||
appForm.q.addEventListener('input', function () {
|
||||
clearTimeout(appSearchTimer);
|
||||
|
||||
Reference in New Issue
Block a user