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:
+46
-4
@@ -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` 도 같은 운영 주소)
|
||||
|
||||
## 유입 기록 상태
|
||||
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
<!-- ===== 아임웹 HTML 위젯 삽입용 코드 (본문 스니펫) - 무료 신청 완료 ===== -->
|
||||
<!-- ※ 신청 위젯(course-enroll.html)과 같은 페이지에 배치하세요. 신청 완료 시 자동으로 표시됩니다. -->
|
||||
<div class="moneyguider-complete">
|
||||
<div class="moneyguider-complete__card">
|
||||
|
||||
<div class="moneyguider-complete__icon" aria-hidden="true">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#1a1200" stroke-width="3"
|
||||
stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="moneyguider-complete__title">신청이 완료되었습니다.</h2>
|
||||
|
||||
<hr class="moneyguider-complete__divider" />
|
||||
|
||||
<div class="moneyguider-complete__info">
|
||||
<div class="moneyguider-complete__row">
|
||||
<span class="moneyguider-complete__label">신청강의</span>
|
||||
<span class="moneyguider-complete__value">[무료한정 😳] 하루 2시간 월1000 버는 AI 효자 상품 찾기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="moneyguider-complete__guide">
|
||||
무료 라이브 단톡방에 입장하셔서<br />
|
||||
<strong>전자책, 무료강의 등 다양한 혜택</strong>을 받아가세요!
|
||||
</p>
|
||||
|
||||
<a class="moneyguider-complete__kakao" href="https://open.kakao.com/o/gWNuX9Eh" target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1a1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 3C6.5 3 2 6.6 2 11c0 2.8 1.9 5.3 4.7 6.7-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.7.1 1.4.2 2 .2 5.5 0 10-3.6 10-8S17.5 3 12 3z" />
|
||||
</svg>
|
||||
오픈카톡 입장하기
|
||||
</a>
|
||||
|
||||
<a class="moneyguider-complete__secondary" href="/home">메인 페이지로 이동</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* moneyguider 신청 완료 위젯 전용 스코프 스타일 (사이트 전역에 영향 없음) */
|
||||
.moneyguider-complete {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* 기본 숨김: 신청 완료 시 JS가 .is-shown 클래스를 추가해 표시 */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.moneyguider-complete.is-shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.moneyguider-complete * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.moneyguider-complete__card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 44px 36px 36px;
|
||||
background-color: #141414;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 체크 아이콘 */
|
||||
.moneyguider-complete__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 22px;
|
||||
background-color: #f5b727;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.moneyguider-complete .moneyguider-complete__title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.02em;
|
||||
text-indent: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.moneyguider-complete__divider {
|
||||
height: 1px;
|
||||
margin: 26px 0;
|
||||
border: 0;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 신청 정보 박스 */
|
||||
.moneyguider-complete__info {
|
||||
padding: 22px 24px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.moneyguider-complete__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #cfcfcf;
|
||||
}
|
||||
|
||||
.moneyguider-complete__value {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 안내 문구 */
|
||||
.moneyguider-complete__guide {
|
||||
margin: 26px 0 22px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.moneyguider-complete__guide strong {
|
||||
font-weight: 700;
|
||||
color: #f5b727;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.moneyguider-complete__kakao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 17px;
|
||||
margin-bottom: 12px;
|
||||
background-color: #fee500;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1200 !important;
|
||||
text-decoration: none;
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.moneyguider-complete__kakao:hover {
|
||||
filter: brightness(0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.moneyguider-complete__secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 17px;
|
||||
background-color: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.moneyguider-complete__secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 520px) {
|
||||
.moneyguider-complete__card {
|
||||
padding: 36px 22px 26px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.moneyguider-complete .moneyguider-complete__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__row {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.moneyguider-complete__value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ========================================================
|
||||
▼▼▼ 여기만 수정하시면 됩니다 ▼▼▼
|
||||
======================================================== */
|
||||
|
||||
/* 1) 비로그인 사용자를 이 화면에서 튕겨낼지.
|
||||
주소창에 /apply 를 직접 쳐서 들어오는 것을 막습니다. */
|
||||
var REQUIRE_LOGIN = true;
|
||||
|
||||
/* 2) 튕겨낼 때 어디로 보낼지
|
||||
'join' 아임웹 회원가입 페이지로 보냅니다. 가입이 끝나면
|
||||
지금 주소(/apply?...)로 그대로 돌아와 완료 화면이
|
||||
보입니다. 신청 흐름이 끊기지 않습니다.
|
||||
주소 그 주소로 이동시킵니다.
|
||||
예) '/free-shop/?idx=52' '/home' */
|
||||
var REDIRECT_TO = 'join';
|
||||
|
||||
/* 3) 회원가입 페이지 주소 ('가입 방식 선택' 화면).
|
||||
back_url 파라미터는 자동으로 붙습니다.
|
||||
detail-buy-btn.html 의 JOIN_PAGE 와 같은 값이어야 합니다.
|
||||
|
||||
※ SITE_MEMBER.openJoinTypeChoice() 를 부르면 같은 화면이
|
||||
현재 페이지 위에 '모달' 로 뜹니다. 이 페이지는 내용을
|
||||
감춰 둔 상태라 모달 뒤가 텅 비어 보이고, 사용자가 모달을
|
||||
닫으면 빈 화면에 갇힙니다. 전체 페이지로 보냅니다. */
|
||||
var JOIN_PAGE = '/site_join_type_choice';
|
||||
|
||||
/* 4) 광고 픽셀 이벤트
|
||||
------------------------------------------------------
|
||||
※ '오픈카톡방에 실제로 입장했는지' 는 우리 쪽에서 알 수
|
||||
없습니다. open.kakao.com 은 카카오 도메인이고, 입장
|
||||
결과를 사이트로 알려주는 콜백이 없습니다.
|
||||
그래서 '입장하기 버튼 클릭' 을 전환으로 잡습니다.
|
||||
실제 입장률만큼 과대 집계되는 점을 감안하세요. */
|
||||
var PIXEL = {
|
||||
/* 켜고 끄기 */
|
||||
ON: true,
|
||||
|
||||
/* (1) 오픈카톡 버튼 클릭 → 이것을 광고 전환으로 잡으시면 됩니다.
|
||||
메타 광고 관리자에는 '구독' 으로 표시됩니다.
|
||||
|
||||
Subscribe 를 고른 이유
|
||||
- 캠페인 목표가 '판매' 면 전환 이벤트 목록에 커머스
|
||||
계열만 나옵니다. Contact / Lead 는 '잠재 고객' 목표
|
||||
전용이라 목록에 아예 안 뜹니다.
|
||||
- Subscribe 는 그 목록 안에 있으면서 아직 아무도
|
||||
쓰지 않는 이벤트입니다. (결제 시작·콘텐츠 조회·
|
||||
장바구니·구매는 아임웹 쇼핑 연동이 이미 사용 중,
|
||||
등록 완료는 아래 가입 전환이 사용 중)
|
||||
- 의미도 '채널 입장 = 구독' 으로 가장 가깝습니다.
|
||||
|
||||
※ 주의 : 상품 타입이 subscribe 로 잡혀 있어
|
||||
(data-bs-prod-type="subscribe"), 정규강의 결제가
|
||||
완료되면 아임웹이 Purchase 대신 Subscribe 를 쏠
|
||||
가능성이 있습니다. 그때는 이벤트 관리자에서 '구독'
|
||||
이벤트의 소스 URL 을 확인해 /apply 외의 주소가
|
||||
섞이는지 보세요. 섞인다면 URL 조건을 건 맞춤 전환으로
|
||||
분리하시면 됩니다.
|
||||
|
||||
맞춤 전환으로 따로 관리하시려면 KAKAO_CUSTOM 을 true 로
|
||||
두세요. 그러면 trackCustom 으로 보냅니다. */
|
||||
KAKAO_EVENT: 'Subscribe',
|
||||
KAKAO_CUSTOM: false,
|
||||
|
||||
/* (2) 회원가입 완료 → CompleteRegistration ('등록 완료')
|
||||
지금은 꺼져 있습니다. 위 (1) 구독 추적만 동작합니다.
|
||||
|
||||
true 로 바꾸면 가입 전환도 함께 집계됩니다. 그때 필요한
|
||||
표식은 detail-buy-btn.html 이 이미 남기고 있으므로
|
||||
여기 값만 바꾸면 되고, 다른 파일은 손대지 않아도 됩니다.
|
||||
|
||||
켤 경우의 동작 : 가입 페이지를 거쳐 돌아온 사람만 셉니다.
|
||||
이미 회원인 사람이 완료 화면에 다시 들른 경우는
|
||||
세지 않습니다. */
|
||||
REGISTRATION: false,
|
||||
|
||||
/* (3) 구글 광고 전환 — 전환 액션 '회원가입 (광섭)'
|
||||
구글은 메타처럼 이벤트 이름으로 구분하지 않고,
|
||||
전환 액션마다 발급되는 send_to 값으로 구분합니다.
|
||||
구글 애즈 > 목표 > 해당 전환 액션 > 태그 설정 에서
|
||||
'AW-계정ID/라벨' 형태로 확인할 수 있습니다.
|
||||
|
||||
비워두면 구글 쪽으로는 아무것도 보내지 않습니다. */
|
||||
GOOGLE_KAKAO: 'AW-17296567556/95baCOj7ze0aEITa0rdA',
|
||||
|
||||
/* 가입 완료 시점에 보낼 구글 전환. 지금은 비워 두었습니다.
|
||||
(아래 REGISTRATION 이 false 라 어차피 동작하지 않습니다)
|
||||
가입 시점으로 옮기시려면 위 값을 여기로 옮기고
|
||||
REGISTRATION 을 true 로 바꾸세요. */
|
||||
GOOGLE_SIGNUP: '',
|
||||
|
||||
/* (4) GA4 이벤트도 함께 보낼지.
|
||||
※ 현재 이 사이트에는 GA4(G-) 속성이 없고 구글 광고
|
||||
태그(AW-)만 설치돼 있습니다. 그래서 켜 두어도 지금은
|
||||
실질적인 효과가 없습니다. 나중에 GA4 를 붙이면
|
||||
그때부터 동작합니다. */
|
||||
GA4: true
|
||||
};
|
||||
|
||||
/* detail-buy-btn.html 이 남기는 표식의 이름. 양쪽이 같아야 합니다. */
|
||||
var JOIN_FLAG = 'mg-join-started';
|
||||
|
||||
/* 6) 상품별 오픈카톡 링크
|
||||
키 : 상품번호 — 주소의 ?idx= 값입니다.
|
||||
detail-buy-btn.html 이 신청 버튼에서 함께 넘겨줍니다.
|
||||
값 : 그 상품 전용 오픈채팅방 주소
|
||||
|
||||
여기에 없는 상품은 아래 KAKAO_DEFAULT 로 갑니다. */
|
||||
var KAKAO_LINKS = {
|
||||
'52': 'https://open.kakao.com/o/gWNuX9Eh'
|
||||
// '10': 'https://open.kakao.com/o/XXXXXXXX',
|
||||
};
|
||||
|
||||
/* 등록되지 않은 상품이거나 상품번호 없이 들어온 경우에 쓸 링크.
|
||||
비워두면('') 오픈카톡 버튼 자체를 숨깁니다.
|
||||
엉뚱한 방으로 보내느니 안 보여주는 편이 낫다면 비우세요. */
|
||||
var KAKAO_DEFAULT = '';
|
||||
|
||||
/* 5) 로그인 여부를 판별하는 데 기다릴 최대 시간.
|
||||
아임웹 전역(MEMBER_UID)이 늦게 실행되는 경우 대비입니다.
|
||||
이 시간이 지나도 판별이 안 되면 '통과' 시킵니다. */
|
||||
var WAIT_MS = 2000;
|
||||
var POLL_MS = 100;
|
||||
|
||||
/* 7) 신청 기록을 보낼 백엔드 주소
|
||||
신청 완료 화면이 뜰 때 회원·상품번호·유입정보·업체 링크 클릭을
|
||||
보내 관리자 화면 '신청 내역'과 '업체 링크' 실적에 남깁니다.
|
||||
비워두면('') 아무것도 보내지 않습니다. */
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api';
|
||||
|
||||
/* ========================================================
|
||||
▲▲▲ 여기까지 ▲▲▲ 아래는 수정할 필요 없습니다.
|
||||
======================================================== */
|
||||
|
||||
var el = document.querySelector('.moneyguider-complete');
|
||||
if (!el) return;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var name = params.get('name');
|
||||
/* 상품번호 — detail-buy-btn.html 이 붙여 보냅니다.
|
||||
오픈카톡 링크를 고르고, 픽셀에도 함께 실어 보냅니다. */
|
||||
var idx = params.get('idx') || '';
|
||||
var isApplyPage = window.location.pathname.indexOf('apply') !== -1;
|
||||
|
||||
// /apply 페이지도 아니고 name 파라미터도 없으면 아무것도 하지 않습니다
|
||||
if (!isApplyPage && !name) return;
|
||||
|
||||
/* ── 픽셀 전송 ────────────────────────────────────────
|
||||
픽셀이 아직 안 떴거나 광고차단기에 막혀도 페이지가 죽지
|
||||
않도록 전부 방어합니다. 완료 화면 표시가 픽셀보다 우선입니다. */
|
||||
function meta(name, custom, params) {
|
||||
if (!PIXEL.ON || typeof window.fbq !== 'function') return;
|
||||
try { window.fbq(custom ? 'trackCustom' : 'track', name, params || {}); }
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
function ga(name, params) {
|
||||
if (!PIXEL.ON || !PIXEL.GA4 || typeof window.gtag !== 'function') return;
|
||||
try { window.gtag('event', name, params || {}); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 구글 광고 전환.
|
||||
|
||||
구글이 안내하는 스니펫은 gtag_report_conversion(url) 형태로,
|
||||
전환을 보낸 뒤 window.location 으로 이동시키는 구조입니다.
|
||||
그건 '같은 탭에서 이동하는 링크' 를 위한 것입니다.
|
||||
우리 카톡 버튼은 target="_blank" 라 현재 페이지가 그대로
|
||||
남으므로 이동을 가로챌 이유가 없습니다. 그 방식을 쓰면
|
||||
새 탭이 열리면서 현재 탭까지 카톡으로 넘어가 버립니다.
|
||||
→ 전환만 보내고 링크는 건드리지 않습니다. */
|
||||
function gads(sendTo) {
|
||||
if (!PIXEL.ON || !sendTo || typeof window.gtag !== 'function') return;
|
||||
try { window.gtag('event', 'conversion', { 'send_to': sendTo }); }
|
||||
catch (e) { }
|
||||
}
|
||||
|
||||
function ssGet(key) {
|
||||
try { return window.sessionStorage.getItem(key); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function ssDrop(key) {
|
||||
try { window.sessionStorage.removeItem(key); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 픽셀 스크립트는 비동기로 뜹니다. 페이지 로드 직후에는 아직
|
||||
window.fbq 가 없을 수 있어 잠깐 기다립니다.
|
||||
|
||||
이게 필요한 이유 : 가입 전환은 '표식을 지우고 → 보낸다' 인데,
|
||||
픽셀이 안 뜬 상태에서 표식부터 지워버리면 그 가입은 영영
|
||||
집계되지 않습니다. 표식은 실제로 보낸 뒤에만 지웁니다. */
|
||||
var PIXEL_WAIT_MS = 4000;
|
||||
|
||||
function whenPixelReady(cb) {
|
||||
var waited = 0;
|
||||
(function poll() {
|
||||
var ready = (typeof window.fbq === 'function');
|
||||
if (ready || waited >= PIXEL_WAIT_MS) { cb(ready); return; }
|
||||
waited += 100;
|
||||
window.setTimeout(poll, 100);
|
||||
})();
|
||||
}
|
||||
|
||||
/* ── 신청 기록 전송 ──────────────────────────────────────
|
||||
실패해도 완료 화면·픽셀에는 영향을 주지 않습니다 (전부 조용히 무시).
|
||||
같은 회원·상품은 서버가 한 건만 남기므로 새로고침해도 안전합니다. */
|
||||
var PRODUCT_IDX_PATTERN = /^\d{1,20}$/;
|
||||
var PRODUCT_NAME_MAX = 200;
|
||||
var INFLOW_MAX = 1000;
|
||||
var applicationRequest = null; /* 신청 기록 ID 를 돌려주는 Promise */
|
||||
|
||||
/* 아임웹 전역(MEMBER_UID)이 늦게 생기는 경우를 잠깐 기다립니다 */
|
||||
function whenMemberUid(cb) {
|
||||
var waited = 0;
|
||||
(function poll() {
|
||||
var uid = window.MEMBER_UID;
|
||||
if (uid) { cb(String(uid)); return; }
|
||||
if (typeof uid === 'string' || waited >= WAIT_MS) return;
|
||||
waited += POLL_MS;
|
||||
window.setTimeout(poll, POLL_MS);
|
||||
})();
|
||||
}
|
||||
|
||||
/* header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값 */
|
||||
function readInflow() {
|
||||
try {
|
||||
var value = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
|
||||
? window.MG_UTM.peek() : '';
|
||||
return typeof value === 'string' ? value.slice(0, INFLOW_MAX) : '';
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/* partner-ref.html(업체 링크 스크립트)이 보관한 클릭 ID — 없으면 빈 배열 */
|
||||
function readPartnerRefs() {
|
||||
try {
|
||||
var refs = window.MG_PARTNER && typeof window.MG_PARTNER.refs === 'function'
|
||||
? window.MG_PARTNER.refs() : [];
|
||||
return Array.isArray(refs) ? refs : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function recordApplication() {
|
||||
if (!API_BASE || !PRODUCT_IDX_PATTERN.test(idx) ||
|
||||
typeof window.fetch !== 'function') return;
|
||||
|
||||
applicationRequest = new Promise(function (resolve) {
|
||||
whenMemberUid(function (memberUid) {
|
||||
var body = { memberUid: memberUid, productIdx: idx };
|
||||
if (name) body.productName = name.slice(0, PRODUCT_NAME_MAX);
|
||||
var inflow = readInflow();
|
||||
if (inflow) body.inflow = inflow;
|
||||
var refs = readPartnerRefs();
|
||||
if (refs.length) body.partnerRefs = refs;
|
||||
|
||||
window.fetch(API_BASE + '/applications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
return res.ok ? res.json() : null;
|
||||
}).then(function (json) {
|
||||
resolve(json && json.success && json.data ? json.data.id : null);
|
||||
}).catch(function () { resolve(null); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 오픈카톡 버튼 클릭 기록 — 서버는 처음 한 번만 남깁니다 */
|
||||
function recordKakaoClick() {
|
||||
if (!applicationRequest) return;
|
||||
applicationRequest.then(function (id) {
|
||||
if (!id) return;
|
||||
window.fetch(API_BASE + '/applications/' + encodeURIComponent(id) +
|
||||
'/kakao-clicks', { method: 'POST', credentials: 'omit' })
|
||||
.catch(function () { });
|
||||
});
|
||||
}
|
||||
|
||||
function show() {
|
||||
el.classList.add('is-shown');
|
||||
|
||||
// URL로 전달된 상품명을 '신청강의' 값에 채움
|
||||
if (name) {
|
||||
var valueEl = el.querySelector('.moneyguider-complete__value');
|
||||
if (valueEl) valueEl.textContent = name;
|
||||
}
|
||||
|
||||
/* 신청 기록 — 아래 카톡 버튼 처리가 중간에 끝날 수 있어 먼저 보냅니다 */
|
||||
recordApplication();
|
||||
|
||||
/* (2) 방금 가입을 마치고 돌아온 경우에만 가입 전환 */
|
||||
if (PIXEL.REGISTRATION && PIXEL.ON && ssGet(JOIN_FLAG)) {
|
||||
whenPixelReady(function (ready) {
|
||||
/* 끝내 안 뜨면(광고차단기 등) 표식을 남겨 둡니다.
|
||||
어차피 못 보낼 상황이고, 지워봐야 손해만 봅니다. */
|
||||
if (!ready) return;
|
||||
|
||||
ssDrop(JOIN_FLAG); /* 보내기 직전에 지웁니다 */
|
||||
meta('CompleteRegistration', false, {
|
||||
content_name: name || '', status: true
|
||||
});
|
||||
gads(PIXEL.GOOGLE_SIGNUP);
|
||||
ga('sign_up', { method: 'imweb', content_name: name || '' });
|
||||
});
|
||||
}
|
||||
|
||||
/* (1) 오픈카톡 입장 버튼 클릭 → 광고 전환으로 쓸 이벤트
|
||||
|
||||
버튼이 target="_blank" 라 이 페이지가 그대로 남습니다.
|
||||
덕분에 이벤트가 전송될 시간이 확보됩니다. 같은 탭에서
|
||||
열도록 바꾸시면 전송 전에 페이지가 떠나 유실될 수 있습니다.
|
||||
|
||||
preventDefault 를 하지 않습니다. 링크는 평소대로 열립니다. */
|
||||
var kakao = el.querySelector('.moneyguider-complete__kakao');
|
||||
if (kakao) {
|
||||
/* 상품번호로 오픈채팅방 주소를 고릅니다.
|
||||
등록된 게 없으면 KAKAO_DEFAULT, 그것도 비어 있으면
|
||||
버튼을 숨깁니다. */
|
||||
var link = (Object.prototype.hasOwnProperty.call(KAKAO_LINKS, idx)
|
||||
? KAKAO_LINKS[idx] : '') || KAKAO_DEFAULT;
|
||||
|
||||
if (!link) {
|
||||
kakao.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
kakao.setAttribute('href', link);
|
||||
|
||||
var sent = false;
|
||||
|
||||
function onKakao() {
|
||||
/* 한 화면에서 여러 번 눌러도 한 번만 집계합니다.
|
||||
target="_blank" 라 페이지가 그대로 남아 있어서
|
||||
두세 번 누르는 일이 실제로 생깁니다. 그대로 두면
|
||||
한 사람이 전환 여러 건으로 잡힙니다. */
|
||||
if (sent) return;
|
||||
sent = true;
|
||||
|
||||
recordKakaoClick();
|
||||
|
||||
/* content_ids 를 함께 보내면 나중에 광고 관리자에서
|
||||
상품별로 전환을 쪼개 볼 수 있습니다. */
|
||||
var p = { content_name: name || '', content_category: 'kakao_openchat' };
|
||||
if (idx) p.content_ids = [idx];
|
||||
meta(PIXEL.KAKAO_EVENT, PIXEL.KAKAO_CUSTOM, p);
|
||||
gads(PIXEL.GOOGLE_KAKAO);
|
||||
ga('kakao_openchat_join', { content_name: name || '' });
|
||||
}
|
||||
|
||||
kakao.addEventListener('click', onKakao);
|
||||
|
||||
/* 가운데 버튼 클릭은 click 이 아니라 auxclick 으로 옵니다.
|
||||
새 탭에서 열리는 건 똑같으므로 함께 집계합니다.
|
||||
(Ctrl+클릭은 button 0 의 click 이라 위에서 이미 잡힙니다) */
|
||||
kakao.addEventListener('auxclick', function (e) {
|
||||
if (e.button === 1) onKakao();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!REQUIRE_LOGIN) { show(); return; }
|
||||
|
||||
/* ── 로그인 판별 ──────────────────────────────────────
|
||||
detail-buy-btn.html 과 같은 방식입니다.
|
||||
window.MEMBER_UID 로그인 회원의 식별자 (비로그인이면 빈 값)
|
||||
_imweb_login_state 로그인 상태 쿠키 ('Y')
|
||||
|
||||
반환값 : 'in' | 'out' | 'unknown'
|
||||
|
||||
※ '확실히 로그아웃(out)' 일 때만 튕겨냅니다. 판별이 안 되면
|
||||
(unknown) 화면을 보여줍니다. 반대로 하면 아임웹 스크립트가
|
||||
늦게 뜬 순간 정상적으로 가입을 마친 사람까지 완료 화면을
|
||||
못 보게 됩니다. */
|
||||
function loginState() {
|
||||
var uid = window.MEMBER_UID;
|
||||
var m = /(?:^|;\s*)_imweb_login_state=([^;]*)/.exec(document.cookie || '');
|
||||
|
||||
if (uid || (m && m[1] === 'Y')) return 'in';
|
||||
if (typeof uid === 'string') return 'out';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/* 가입 직후 돌아왔는데 판별이 한 박자 늦어 다시 튕겨내는
|
||||
무한 왕복을 막습니다. 세션당 한 번만 튕겨냅니다. */
|
||||
var ONCE_KEY = 'mg-apply-bounced';
|
||||
|
||||
function bouncedBefore() {
|
||||
try { return window.sessionStorage.getItem(ONCE_KEY) === '1'; }
|
||||
catch (e) { return false; } /* 시크릿 모드 등 - 막지 않습니다 */
|
||||
}
|
||||
|
||||
function markBounced() {
|
||||
try { window.sessionStorage.setItem(ONCE_KEY, '1'); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 아임웹 복귀 주소 형식 : encodeURIComponent(btoa(주소)) */
|
||||
function backUrl(url) {
|
||||
try { return encodeURIComponent(window.btoa(url)); }
|
||||
catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/* 튕겨냅니다. 성공하면 true (= 완료 화면을 보여주지 않음) */
|
||||
function bounce() {
|
||||
if (bouncedBefore()) return false;
|
||||
markBounced();
|
||||
|
||||
if (REDIRECT_TO !== 'join') {
|
||||
/* replace 를 쓰는 이유 : 뒤로가기로 다시 /apply 에
|
||||
들어와 또 튕기는 일이 없게 합니다. */
|
||||
window.location.replace(REDIRECT_TO);
|
||||
return true;
|
||||
}
|
||||
|
||||
var back = backUrl(window.location.href);
|
||||
if (!back) return false;
|
||||
|
||||
window.location.replace(JOIN_PAGE +
|
||||
(JOIN_PAGE.indexOf('?') === -1 ? '?' : '&') +
|
||||
'back_url=' + back);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* 판별될 때까지 잠깐 기다립니다. 그동안 완료 화면은 숨은 채로
|
||||
둡니다. 먼저 보여줬다가 튕겨내면 내용이 한 번 번쩍입니다. */
|
||||
var waited = 0;
|
||||
(function check() {
|
||||
var state = loginState();
|
||||
|
||||
if (state === 'unknown' && waited < WAIT_MS) {
|
||||
waited += POLL_MS;
|
||||
window.setTimeout(check, POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'out' && bounce()) return;
|
||||
show();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<!-- ===== 코드 끝 ===== -->
|
||||
+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');
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!-- ===== 아임웹 Header Code 삽입용 — 업체 링크 추적 (partner-ref) ===== -->
|
||||
<!--
|
||||
어디에 넣나 : 아임웹 관리자 > 환경설정 > SEO/HTML > Header Code (사이트 전체)
|
||||
header-top.html(UTM 스크립트)과 함께 두어도 서로 영향이 없습니다.
|
||||
|
||||
하는 일
|
||||
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
|
||||
mg_ref(클릭 ID)를 이 브라우저에 30일간 보관하고, 주소창에서는 지웁니다.
|
||||
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
|
||||
2) 로그인한 회원이 확인되면 "이 회원이 이 링크로 들어왔다"를 서버에 한 번 알립니다.
|
||||
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속합니다.
|
||||
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
|
||||
신청 기록에 함께 보냅니다.
|
||||
|
||||
저장하는 값 : 클릭 ID 와 보관 시각만 (회원 정보는 저장하지 않습니다)
|
||||
-->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* 백엔드 주소 */
|
||||
var API_BASE = 'https://moneyguide.wandergo.net/api';
|
||||
|
||||
/* 아래는 수정할 필요 없습니다 — 백엔드(shared/utils/partner-link.ts)와 같은 값입니다 */
|
||||
var PARAM = 'mg_ref';
|
||||
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
|
||||
var STORE_KEY = 'mg_partner_refs'; /* [{ ref, at }] — 최근 것이 뒤 */
|
||||
var SENT_KEY = 'mg_partner_sent'; /* 이미 서버에 알린 클릭 ID */
|
||||
var WINDOW_MS = 30 * 24 * 60 * 60 * 1000; /* 귀속 기간 30일 */
|
||||
var MAX_REFS = 20;
|
||||
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생깁니다 */
|
||||
var WAIT_MS = 5000;
|
||||
var POLL_MS = 200;
|
||||
|
||||
function readJson(key) {
|
||||
try {
|
||||
var value = JSON.parse(window.localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
|
||||
}
|
||||
|
||||
/* 30일이 지났거나 형식이 아닌 값은 버립니다 */
|
||||
function freshRefs() {
|
||||
var now = Date.now();
|
||||
return readJson(STORE_KEY).filter(function (item) {
|
||||
return item && REF_PATTERN.test(item.ref) &&
|
||||
typeof item.at === 'number' && now - item.at <= WINDOW_MS;
|
||||
});
|
||||
}
|
||||
|
||||
/* 1) 주소의 클릭 ID 보관 + 주소창 정리 */
|
||||
var refs = freshRefs();
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
var ref = url.searchParams.get(PARAM);
|
||||
if (ref && REF_PATTERN.test(ref)) {
|
||||
refs = refs.filter(function (item) { return item.ref !== ref; });
|
||||
refs.push({ ref: ref, at: Date.now() });
|
||||
if (refs.length > MAX_REFS) refs = refs.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) { }
|
||||
writeJson(STORE_KEY, refs);
|
||||
|
||||
/* 3) 다른 위젯이 읽는 창구 */
|
||||
window.MG_PARTNER = {
|
||||
refs: function () {
|
||||
return freshRefs().map(function (item) { return item.ref; });
|
||||
}
|
||||
};
|
||||
|
||||
/* 2) 로그인 회원 ↔ 클릭 연결 */
|
||||
function report(memberUid) {
|
||||
var current = window.MG_PARTNER.refs();
|
||||
var sent = readJson(SENT_KEY);
|
||||
var pending = current.filter(function (r) { return sent.indexOf(r) === -1; });
|
||||
if (pending.length === 0 || 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; /* 실패하면 다음 페이지에서 다시 보냅니다 */
|
||||
/* 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 합니다 */
|
||||
writeJson(SENT_KEY, sent.concat(pending).filter(function (r) {
|
||||
return current.indexOf(r) !== -1;
|
||||
}));
|
||||
}).catch(function () { });
|
||||
}
|
||||
|
||||
if (refs.length === 0) 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>
|
||||
<!-- ===== 코드 끝 ===== -->
|
||||
Reference in New Issue
Block a user