Files
moneyguide/imweb/imweb-header-top.html
T
ttipo 5badf94dc7 feat(imweb): 주문완료 화면에서 구매 때 유입 전송·관리자 화면에 표시
- Header Code '구매 때 유입 전송': /shop_payment_complete 에서 order_no·유입·업체 클릭 ID 를
  POST /api/order-inflows 로 한 번 보낸다 (같은 탭 새로고침 때는 다시 보내지 않는다)
- 결제 페이지(/shop_payment*)에서는 리퍼러를 유입으로 기록하지 않는다 (v1.3) —
  결제창에서 돌아올 때 pay.kakao.com 이 '카카오 소셜', pay.naver.com 이 '네이버 검색'
  유입으로 잡혀 구매 때 유입을 가릴 수 있었다
- 관리자 화면: 유입 기준을 '신청·구매 때 유입'으로, 구매 행·상세 패널에 구매 때 유입,
  업체 실적 상세의 비회원 구매는 주문자 이름으로 보여준다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:14:42 +09:00

718 lines
32 KiB
HTML

<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
<!-- ============================================================
[자본주의가이드] 업체 링크 입구 v1.0 — 이 블록은 반드시 맨 위에 둔다
업체에 전달하는 링크: https://www.moneyguider.co.kr/go?c=코드
→ 페이지를 그리기 전에 백엔드 단축 링크(/r/코드)로 바로 넘긴다.
백엔드가 클릭을 기록하고 클릭 ID(mg_ref)와 업체 UTM 을 붙여 상품 페이지로 보낸다.
필요: 아임웹에 주소가 'go' 인 페이지 (메뉴에 숨김, 전체 공개). 내용은 비워 둬도 된다.
아래 UTM 스크립트는 이 입구 페이지에서 기록하지 않는다(MG_PARTNER_ENTRY) —
기록은 이동한 상품 페이지에서 업체 UTM 으로 한 번만 남긴다.
============================================================ -->
<script>
(function () {
'use strict';
var ENTRY_PATH = /^\/go\/?$/; // 백엔드 PARTNER_ENTRY_PATH 와 같아야 한다
var ENTRY_PARAM = 'c'; // 백엔드 PARTNER_ENTRY_PARAM
var CODE_PATTERN = /^[0-9A-Za-z]{8}$/; // 백엔드 LINK_CODE_PATTERN
var SHORT_LINK_BASE = 'https://moneyguide.wandergo.net/r/'; // 백엔드 단축 링크
try {
if (!ENTRY_PATH.test(location.pathname)) return;
var code = new URLSearchParams(location.search).get(ENTRY_PARAM);
if (!code || !CODE_PATTERN.test(code)) return;
window.MG_PARTNER_ENTRY = true;
location.replace(SHORT_LINK_BASE + code); // replace — 뒤로가기로 입구에 다시 오지 않게
} catch (e) { }
})();
</script>
<!-- ============================================================
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.3
v1.2: 업체 링크(utm_medium=partner) 방문은 보관 중인 최초유입을 덮어쓴다
v1.3: 결제 페이지(/shop_payment*)에서는 결제창 리퍼러를 유입으로 기록하지 않는다
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
> 고급 > 공통 코드 삽입 > "Header Code"
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
+ 기본 항목 "홈페이지" (URL 형식으로 같은 정보 기록)
사용자 정의 항목은 Open API 회원 조회에 나오지 않고,
홈페이지는 homePage 필드로 나오기 때문에 둘 다 채운다.
커버 범위: 카카오 1초 회원가입 / 카카오 소셜 가입 / 아임웹 일반 회원가입
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var CFG = {
FIELD_ID: 'join_64454', // 아임웹이 렌더링하는 input id (name=join_form_64454)
FIELD_LABEL: '유입경로', // id를 못 찾을 때 라벨 텍스트로 재탐색 (항목 재생성 대비)
COOKIE_DOMAIN: '.moneyguider.co.kr', // www / apex 양쪽 공유
TTL_DAYS: 2, // 최초유입 보관 기간
HIDE_FIELD: true, // true=사용자에게 숨김, false=읽기전용으로 노출
HP_ENABLED: true, // 홈페이지 항목에도 유입정보 기록 (Open API homePage 로 조회용)
HP_LABEL: '홈페이지', // 홈페이지 입력칸을 name/id로 못 찾을 때 라벨로 재탐색
OWN_HOST: 'moneyguider.co.kr',
PARTNER_MEDIUM: 'partner', // 업체 링크 UTM 의 utm_medium (백엔드 PARTNER_UTM_MEDIUM 과 같아야 한다)
DEBUG: false // true로 바꾸면 콘솔에 로그 출력
};
var K_FIRST = 'mg_utm_first'; // 최초 유입 (first-touch)
var K_LAST = 'mg_utm_last'; // 최종 유입 (last-touch)
function log() {
if (CFG.DEBUG && window.console && console.log) {
console.log.apply(console, ['[MG-UTM]'].concat([].slice.call(arguments)));
}
}
/* ============ 저장소: localStorage + 쿠키 이중화 ============
카카오 인앱브라우저 / Safari ITP 등에서 localStorage가 막히거나
초기화되는 경우를 대비해 쿠키에도 동일 값을 기록한다. */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * 86400000);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() +
';path=/;domain=' + CFG.COOKIE_DOMAIN + ';SameSite=Lax';
}
/* localStorage에는 만료 개념이 없으므로 저장값 안에 만료 시각(_e)을 함께 넣고
읽을 때 직접 확인한다. 이렇게 해야 쿠키가 지워진 뒤에도 TTL_DAYS가 실제로 지켜진다. */
function saveTouch(k, t) {
var o = merge(t, { _e: Date.now() + CFG.TTL_DAYS * 86400000 });
var json = JSON.stringify(o);
lsSet(k, json);
ckSet(k, json, CFG.TTL_DAYS);
}
function dropTouch(k) {
try { localStorage.removeItem(k); } catch (e) { }
ckSet(k, '', -1);
}
function readTouch(k) {
var raw = lsGet(k) || ckGet(k);
if (!raw) return null;
var o = null;
try { o = JSON.parse(raw); } catch (e) { dropTouch(k); return null; }
if (!o || typeof o !== 'object') { dropTouch(k); return null; }
if (o._e && Date.now() > o._e) { dropTouch(k); return null; } // 보관기간 경과
delete o._e;
return o;
}
/* ===================== 유틸 ===================== */
// 파이프 포맷을 깨뜨리는 문자 제거 + 길이 제한
function clean(v, n) {
return String(v == null ? '' : v)
.replace(/[|=\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, n || 80);
}
function parseQuery() {
var out = {}, s = location.search.replace(/^\?/, '');
if (!s) return out;
var parts = s.split('&');
for (var i = 0; i < parts.length; i++) {
if (!parts[i]) continue;
var p = parts[i], j = p.indexOf('=');
var k = j < 0 ? p : p.slice(0, j);
var v = j < 0 ? '' : p.slice(j + 1);
try { out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')); }
catch (e) { out[k] = v; }
}
return out;
}
function merge(a, b) {
var o = {}, k;
for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) o[k] = a[k];
for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) o[k] = b[k];
return o;
}
// 한국 시간 기준 날짜 (toISOString 은 UTC 라서 0~9시에는 전날로 찍힌다)
function today() { return new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10); }
/* ============ 리퍼러 분류 (UTM이 없는 자연유입 보정) ============ */
var REF_MAP = [
[/^(m\.)?(blog|cafe|post|in)\.naver\.com$/i, 'naver_blog', 'referral'],
[/(^|\.)naver\.com$/i, 'naver', 'organic'],
[/(^|\.)google\./i, 'google', 'organic'],
[/(^|\.)bing\.com$/i, 'bing', 'organic'],
[/(^|\.)daum\.net$/i, 'daum', 'organic'],
[/(^|\.)kakao\.com$/i, 'kakao', 'social'],
[/(^|\.)instagram\.com$/i, 'instagram', 'social'],
[/(^|\.)(facebook|fb)\.com$/i, 'facebook', 'social'],
[/(^|\.)(youtube\.com|youtu\.be)$/i, 'youtube', 'social'],
[/(^|\.)tiktok\.com$/i, 'tiktok', 'social'],
[/(^|\.)threads\.(net|com)$/i, 'threads', 'social'],
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
];
/* 인증·가입·결제 관련 페이지.
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
결제도 같다 — 결제창(카카오페이 pay.kakao.com, 네이버페이 pay.naver.com, 카드사 등)에서
주문완료(/shop_payment_complete)로 돌아올 때의 리퍼러가 '카카오 소셜'·'네이버 검색'
유입으로 잡혀 구매 때 유입을 가린다.
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join|shop_payment)/i;
/* 소셜 로그인 인증 도메인.
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
사이트의 일반 페이지로 곧장 되돌아오는 경우는 못 막는다.
예) 신청 버튼 → 가입 → 카카오 인증 → /apply 로 복귀
이때 /apply 는 AUTH_PATH에 걸리지 않고 리퍼러가
kauth.kakao.com 이라, REF_MAP이 '카카오 소셜 유입'으로
오인해 최종유입을 덮어쓴다. 원래 유입원(네이버 광고 등)이
지워지므로 리퍼러 쪽에서도 막는다.
※ kakao.com / naver.com 전체가 아니라 인증 전용 호스트만 넣는다.
카카오톡 공유로 들어온 진짜 유입까지 무시하면 안 된다. */
var AUTH_REF = /^(kauth\.kakao\.com|accounts\.kakao\.com|nid\.naver\.com|accounts\.google\.com|appleid\.apple\.com)$/i;
// 광고 클릭 ID -> 매체 추정 (UTM이 누락된 광고 트래픽 보정)
var CID_SRC = {
gclid: 'google', gbraid: 'google', wbraid: 'google',
fbclid: 'meta', kclid: 'kakao', ttclid: 'tiktok',
msclkid: 'bing', yclid: 'yandex', n_ad_group: 'naver'
};
/* ============ 현재 페이지의 유입정보 추출 ============ */
function buildTouch() {
var q = parseQuery();
var t = {};
var hasUtm = false;
var UTM = {
utm_source: 'src', utm_medium: 'med', utm_campaign: 'cmp',
utm_term: 'trm', utm_content: 'cnt'
};
for (var k in UTM) {
if (q[k]) { t[UTM[k]] = clean(q[k]); hasUtm = true; }
}
// 광고 클릭 ID
for (var cid in CID_SRC) {
if (q[cid]) {
t.cid = cid;
if (!hasUtm) { t.src = CID_SRC[cid]; t.med = 'cpc'; hasUtm = true; }
break;
}
}
// 리퍼러 (외부 도메인일 때만)
var ref = '';
try {
if (document.referrer) {
var h = document.referrer.split('/')[2] || '';
h = h.split('?')[0];
if (h && h.indexOf(CFG.OWN_HOST) === -1) ref = h.toLowerCase();
}
} catch (e) { }
if (!hasUtm) {
if (ref) {
var named = null;
for (var i = 0; i < REF_MAP.length; i++) {
if (REF_MAP[i][0].test(ref)) { named = REF_MAP[i]; break; }
}
t.src = named ? named[1] : clean(ref, 40);
t.med = named ? named[2] : 'referral';
} else {
t.src = 'direct';
t.med = 'none';
}
}
if (ref) t.ref = clean(ref, 40);
t.lp = clean(location.pathname, 60);
// 아임웹 상품/게시글 식별자 (/free-shop/?idx=52 처럼 idx로 페이지가 갈리는 경우)
if (q.idx) t.pid = clean(q.idx, 20);
t.ts = today();
t._utm = hasUtm; // URL에 UTM/클릭ID가 실제로 있었는지 (저장 전 제거)
return t;
}
/* ============ 유입정보 기록 ============ */
function capture() {
var t = buildTouch();
var explicitUtm = t._utm;
delete t._utm;
// 소셜 로그인 왕복(kauth.kakao.com 등)을 새 유입으로 오해하지 않도록,
// 인증·가입 페이지에서는 URL에 UTM이 직접 붙어 있을 때만 기록한다.
if (AUTH_PATH.test(location.pathname) && !explicitUtm) return;
// 인증을 마치고 일반 페이지(/apply 등)로 곧장 복귀한 경우도 동일하게 무시.
if (t.ref && AUTH_REF.test(t.ref) && !explicitUtm) return;
var isDirect = (t.src === 'direct');
// 업체 링크로 온 방문 — 업체 단축 링크가 붙인 UTM 이 주소에 실제로 있을 때만
var isPartner = explicitUtm && t.med === CFG.PARTNER_MEDIUM;
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
if (!isDirect) saveTouch(K_LAST, t);
var prev = readTouch(K_FIRST);
if (!prev) {
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
} else if (isPartner) {
// 업체 링크는 이전 유입(광고 등)보다 우선한다 — 신청 때 유입·가입 경로가
// 업체 실적(가장 최근에 누른 업체 링크로 귀속)과 같은 업체를 가리키게 한다
saveTouch(K_FIRST, t);
} else if (!isDirect && prev.src === 'direct') {
saveTouch(K_FIRST, t); // 이전 기록이 direct였다면 실제 유입원으로 승격
}
log('capture', t);
}
/* ============ 필드에 넣을 최종 값 계산 ============ */
function resolve() {
var f = readTouch(K_FIRST);
var l = readTouch(K_LAST);
var t = f || l || { src: 'direct', med: 'none', lp: clean(location.pathname, 60), ts: today() };
// 최초유입과 최종유입이 다르면 last 항목을 덧붙임 (재방문 전환 추적)
if (f && l && (f.src !== l.src || f.cmp !== l.cmp)) {
t = merge(t, { last: clean(l.src + (l.cmp ? '/' + l.cmp : ''), 60) });
}
t.jd = today(); // 가입일
return t;
}
var ORDER = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts', 'last', 'jd'];
/* 홈페이지 항목용 순서. 길이 제한(maxlength)에 걸리면 뒤에서부터 뺀다.
jd(기록일)는 API 의 joinTime 과 비교해, 가입 때가 아니라 나중에
회원정보 수정 화면에서 채워진 값을 걸러내는 데 쓰므로 src/med 바로 뒤에 둔다. */
var HP_ORDER = ['src', 'med', 'jd', 'cmp', 'cnt', 'trm', 'last', 'cid', 'lp', 'pid', 'ref', 'ts'];
function serialize(t, asUrl, order) {
order = order || ORDER;
var pairs = [];
for (var i = 0; i < order.length; i++) {
var k = order[i];
if (t[k]) pairs.push([k, t[k]]);
}
if (asUrl) {
// 항목 형식이 'URL'인 동안의 호환 모드 (HTML5 url 검증 통과용)
var qsv = [];
for (var j = 0; j < pairs.length; j++) {
qsv.push(encodeURIComponent(pairs[j][0]) + '=' + encodeURIComponent(pairs[j][1]));
}
return 'https://utm.' + CFG.OWN_HOST + '/?' + qsv.join('&');
}
var out = [];
for (var n = 0; n < pairs.length; n++) out.push(pairs[n][0] + '=' + pairs[n][1]);
return out.join('|');
}
function fallbackValue(asUrl) {
return asUrl ? ('https://utm.' + CFG.OWN_HOST + '/?src=direct&med=none')
: 'src=direct|med=none';
}
// 홈페이지 항목 값: 항상 URL 형식, maxlength 가 있으면 넘지 않게 낮은 우선순위부터 뺀다
function homepageValue(el) {
var t = resolve();
var max = el && el.maxLength > 0 ? el.maxLength : 0;
var order = HP_ORDER.slice();
var v = serialize(t, true, order);
while (max && v.length > max && order.length > 2) { // src, med 는 끝까지 남긴다
order.pop();
v = serialize(t, true, order);
}
return v;
}
/* ============ 대상 필드 찾기 ============
기본은 고정 id(join_64454). 관리자에서 항목을 지웠다 다시 만들면 아임웹이
새 인덱스를 발급하므로, id를 못 찾으면 라벨 텍스트로 한 번 더 찾는다. */
function findField() {
var el = document.getElementById && document.getElementById(CFG.FIELD_ID);
if (el) return el;
if (!CFG.FIELD_LABEL || !document.querySelectorAll) return null;
try {
var labels = document.querySelectorAll('#join_form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.FIELD_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector(
'input[name^="join_form_"], select[name^="join_form_"], textarea[name^="join_form_"]');
if (input) {
log('id 미발견 - 라벨로 찾음', input.name);
return input;
}
}
} catch (e) { log('findField 실패', e); }
return null;
}
/* 홈페이지 입력칸. 아임웹 기본 항목이라 name/id 가 고정일 가능성이 높지만,
항목을 켠 뒤의 실제 마크업을 확인하기 전이므로 후보 이름과 라벨로 찾는다. */
function findHomepage() {
if (!CFG.HP_ENABLED || !document.querySelector) return null;
try {
var el = document.querySelector(
'input[name="homepage"], input[name="homePage"], input[name="home_page"], ' +
'input[id*="homepage"], input[id*="homePage"], input[name*="homepage"]');
if (el) return el;
var labels = document.querySelectorAll('form label');
for (var i = 0; i < labels.length; i++) {
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
if (txt.indexOf(CFG.HP_LABEL) === -1) continue;
var blk = labels[i].parentElement;
var input = blk && blk.querySelector('input[type="url"], input[type="text"]');
if (input) return input;
}
} catch (e) { log('findHomepage 실패', e); }
return null;
}
/* ============ 회원가입 폼에 주입 ============ */
// 회원가입 폼(#join_form) 안의 입력칸인지
function inJoinForm(el) {
return !!(el.form && el.form.id === 'join_form');
}
function conceal(el) {
if (el.getAttribute('data-mg-utm') === '1') return;
el.setAttribute('data-mg-utm', '1');
var block = el.closest ? el.closest('.input_block') : null;
if (CFG.HIDE_FIELD) {
if (block) block.style.display = 'none';
else el.style.display = 'none';
} else {
el.readOnly = true;
el.setAttribute('tabindex', '-1');
el.style.background = '#f5f6f7';
el.style.color = '#8a8f98';
}
}
function put(el, value) {
// 가입 폼이 아닌 곳(회원정보 수정 화면 등)에 이미 값이 있으면 가입 때 기록된 값이므로 덮어쓰지 않는다
if (el.value && !inJoinForm(el)) { conceal(el); return; }
el.value = value;
// HTML5 검증 실패 시 URL 포맷으로 폴백 (항목 형식이 URL인 경우 방어)
if (el.checkValidity && !el.checkValidity()) {
el.value = serialize(resolve(), true);
if (!el.checkValidity()) el.value = fallbackValue(true);
}
// 아임웹 자체 검증 로직에 값 변경을 알림
var evts = ['input', 'change', 'keyup', 'blur'];
for (var i = 0; i < evts.length; i++) {
try { el.dispatchEvent(new Event(evts[i], { bubbles: true })); } catch (e) { }
}
if (window.jQuery) { try { jQuery(el).trigger('change').trigger('blur'); } catch (e) { } }
conceal(el);
log('fill', el.name || el.id, el.value);
}
function fill() {
var el = findField();
if (!el) return false;
put(el, serialize(resolve(), el.type === 'url'));
// 홈페이지 항목은 같은 폼에 함께 렌더링되므로 유입경로 칸을 찾은 시점에 같이 채운다
var hp = findHomepage();
if (hp) put(hp, homepageValue(hp));
return true;
}
/* ============ 폼 제출 직전 최종 방어 ============
필수 + 숨김 상태에서 값이 비면 브라우저가 제출을 막으므로
어떤 경우에도 빈 값으로 남지 않게 한다. */
function guard() {
function ensure() {
var el = findField();
if (el && !el.value) {
var asUrl = (el.type === 'url');
el.value = serialize(resolve(), asUrl) || fallbackValue(asUrl);
log('guard refill', el.value);
}
var hp = findHomepage();
if (hp && !hp.value) {
hp.value = homepageValue(hp) || fallbackValue(true);
log('guard refill homepage', hp.value);
}
}
document.addEventListener('submit', ensure, true);
document.addEventListener('click', function (e) {
var tg = e.target;
if (!tg || !tg.closest) return;
if (tg.closest('button, input[type=submit], a')) ensure();
}, true);
}
/* ===================== 부팅 ===================== */
// 업체 링크 입구(/go?c=코드)는 곧바로 다른 페이지로 넘어간다 — 여기서 유입을 기록하면
// 업체가 링크를 올린 사이트가 최초유입으로 굳어 업체 UTM 이 최종유입으로만 남는다
if (window.MG_PARTNER_ENTRY) return;
capture();
function start() {
guard();
if (fill()) return;
// 아임웹은 폼을 지연 렌더링/재렌더링하므로 DOM 변화를 감시
var stop = false, mo = null;
function done() {
stop = true;
if (mo) { try { mo.disconnect(); } catch (e) { } }
}
if (window.MutationObserver && document.body) {
mo = new MutationObserver(function () { if (!stop && fill()) done(); });
mo.observe(document.body, { childList: true, subtree: true });
}
// 관측자 미지원/누락 대비 폴링 (최대 20초)
var tries = 0;
var timer = setInterval(function () {
if (stop || fill() || ++tries > 40) { clearInterval(timer); done(); }
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
// 디버그용 핸들: 콘솔에서 MG_UTM.peek() 실행
window.MG_UTM = {
peek: function () {
var raw = lsGet(K_FIRST) || ckGet(K_FIRST);
var exp = null;
try { var o = JSON.parse(raw); if (o && o._e) exp = new Date(o._e).toISOString().slice(0, 10); } catch (e) { }
return {
first: readTouch(K_FIRST),
last: readTouch(K_LAST),
expiresOn: exp, // 이 날짜가 지나면 최초유입이 초기화됨
value: serialize(resolve(), false),
homepage: homepageValue(findHomepage()) // 홈페이지 항목에 들어갈 값
};
},
reset: function () {
dropTouch(K_FIRST); dropTouch(K_LAST);
return 'cleared';
},
refill: fill
};
})();
</script>
<!-- ============================================================
[자본주의가이드] 업체 링크 추적 v1.0
위 UTM 스크립트와 따로 동작한다 (한쪽이 실패해도 다른 쪽은 영향 없음).
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 30일간 보관하고 주소창에서는 지운다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 서버에 한 번 알린다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속한다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보낸다.
저장: UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 이중화
(www ↔ apex 이동, 카카오 인앱브라우저·Safari ITP 대비).
클릭 ID 와 보관 시각만 저장한다 — 회원 정보는 저장하지 않는다.
============================================================ -->
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 백엔드 주소
var COOKIE_DOMAIN = '.moneyguider.co.kr'; // 위 UTM 스크립트와 같은 값
/* 아래는 백엔드(shared/utils/partner-link.ts)와 같아야 한다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var TTL_DAYS = 30; // 귀속 기간
var DAY_MS = 86400000;
var MAX_REFS = 10; // 쿠키가 커지지 않게 최근 것만 둔다
var K_REFS = 'mg_partner_refs';
var K_SENT = 'mg_partner_sent'; // 이미 서버에 알린 클릭 ID
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생긴다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
/* ============ 저장소: localStorage + 쿠키 이중화 ============ */
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
function ckGet(k) {
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
if (!m) return null;
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
}
function ckSet(k, v, days) {
var d = new Date();
d.setTime(d.getTime() + days * DAY_MS);
document.cookie = k + '=' + encodeURIComponent(v) +
';expires=' + d.toUTCString() + ';path=/;domain=' + COOKIE_DOMAIN + ';SameSite=Lax';
}
function read(k) { return lsGet(k) || ckGet(k) || ''; }
function write(k, v) { lsSet(k, v); ckSet(k, v, TTL_DAYS); }
/* 저장 형식 'ref~보관시각(36진수),…' — 쿠키 크기를 줄이려고 JSON 대신 짧게 쓴다.
30일이 지났거나 형식이 아닌 값은 버린다. */
function loadRefs() {
var out = [], now = Date.now(), parts = read(K_REFS).split(',');
for (var i = 0; i < parts.length; i++) {
var p = parts[i].split('~');
var at = parseInt(p[1], 36);
if (REF_PATTERN.test(p[0]) && at > 0 && now - at <= TTL_DAYS * DAY_MS) {
out.push({ ref: p[0], at: at });
}
}
return out;
}
function saveRefs(list) {
var parts = [];
for (var i = 0; i < list.length; i++) parts.push(list[i].ref + '~' + list[i].at.toString(36));
write(K_REFS, parts.join(','));
}
function refsOnly(list) {
var out = [];
for (var i = 0; i < list.length; i++) out.push(list[i].ref);
return out;
}
/* ============ 1) 주소의 클릭 ID 보관 + 주소창 정리 ============ */
var refs = loadRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
var kept = [];
for (var i = 0; i < refs.length; i++) if (refs[i].ref !== ref) kept.push(refs[i]);
kept.push({ ref: ref, at: Date.now() }); // 최근 것이 뒤
refs = kept.slice(-MAX_REFS);
}
if (url.searchParams.has(PARAM)) {
url.searchParams.delete(PARAM);
window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash);
}
} catch (e) { }
saveRefs(refs);
/* ============ 3) 다른 위젯이 읽는 창구 ============ */
window.MG_PARTNER = {
refs: function () { return refsOnly(loadRefs()); }
};
/* ============ 2) 로그인 회원 ↔ 클릭 연결 ============ */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = read(K_SENT) ? read(K_SENT).split(',') : [];
var pending = [];
for (var i = 0; i < current.length; i++) {
if (sent.indexOf(current[i]) === -1) pending.push(current[i]);
}
if (!pending.length || typeof window.fetch !== 'function') return;
window.fetch(API_BASE + '/partner-touches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ memberUid: memberUid, refs: pending })
}).then(function (res) {
if (!res.ok) return; // 실패하면 다음 페이지에서 다시 보낸다
// 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 한다
var next = [];
var all = sent.concat(pending);
for (var j = 0; j < all.length; j++) {
if (current.indexOf(all[j]) !== -1 && next.indexOf(all[j]) === -1) next.push(all[j]);
}
write(K_SENT, next.join(','));
}).catch(function () { });
}
if (!refs.length) return; // 보관한 클릭이 없으면 할 일이 없다
var waited = 0;
(function poll() {
var uid = window.MEMBER_UID;
if (uid) { report(String(uid)); return; }
// 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도된다
if (typeof uid === 'string' || waited >= WAIT_MS) return;
waited += POLL_MS;
window.setTimeout(poll, POLL_MS);
})();
})();
</script>
<!-- ============================================================
[자본주의가이드] 구매 때 유입 전송 v1.0
위 두 스크립트(MG_UTM · MG_PARTNER) 뒤에 있어야 한다.
주문완료 화면(/shop_payment_complete?order_no=주문번호)에서 주문번호와
그 순간의 유입정보(MG_UTM.peek().value), 업체 링크 클릭 ID(MG_PARTNER.refs())를
서버에 한 번 보낸다.
→ 서버가 아임웹 주문 동기화(1시간마다) 때 같은 주문번호의 구매에 붙인다.
구매 때 유입이 남고, 비회원 구매도 업체 링크 실적으로 잡힌다.
무통장입금은 입금이 확인돼 구매로 잡힐 때 붙는다.
같은 주문번호는 서버가 처음 값만 남기므로 새로고침해도 안전하다.
============================================================ -->
<script>
(function () {
'use strict';
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 위 업체 링크 추적과 같은 값
var COMPLETE_PATH = /^\/shop_payment_complete\/?$/; // 아임웹 주문완료 페이지
var ORDER_PARAM = 'order_no'; // 아임웹 API 의 orderNo 와 같은 값
var ORDER_NO_PATTERN = /^\d{1,30}$/; // 백엔드 ORDER_NO_PATTERN 과 같아야 한다
var INFLOW_MAX = 1000;
var K_SENT = 'mg_order_sent'; // 이 탭에서 이미 보낸 주문번호 (새로고침 때 다시 보내지 않게)
function ssGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
function ssSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { } }
try {
if (!COMPLETE_PATH.test(location.pathname)) return;
var orderNo = new URLSearchParams(location.search).get(ORDER_PARAM);
if (!orderNo || !ORDER_NO_PATTERN.test(orderNo)) return;
if (typeof window.fetch !== 'function' || ssGet(K_SENT) === orderNo) return;
var body = { orderNo: orderNo };
try {
var peeked = window.MG_UTM && window.MG_UTM.peek();
var value = peeked && peeked.value;
if (typeof value === 'string' && value) body.inflow = value.slice(0, INFLOW_MAX);
} catch (e) { }
try {
var refs = window.MG_PARTNER && window.MG_PARTNER.refs();
if (refs && refs.length) body.partnerRefs = refs;
} catch (e) { }
window.fetch(API_BASE + '/order-inflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify(body)
}).then(function (res) {
if (res.ok) ssSet(K_SENT, orderNo); // 실패하면 새로고침 때 다시 보낸다
}).catch(function () { });
} catch (e) { }
})();
</script>