feat(installer): 약관을 한 건씩 페이지로 — 개별 동의 + 상단 스크롤 + 끝까지 읽기 강제

- "위 모든 약관에 동의합니다" 통합 체크(탭 방식)를 약관별 페이지 흐름으로 변경.
  각 약관마다 "해당 약관에 동의합니다" 체크박스, [다음] 이면 다음 약관 페이지로.
- 다음 약관을 보여줄 때 화면(pageHost)을 맨 위로 스크롤해 처음부터 읽게 함.
- 본문을 끝까지 내려 읽어야 동의 체크가 활성화(짧아서 스크롤 불필요하면 즉시 허용).
  뒤로/앞으로 이동 시 이전 동의 상태 유지.
- installer / installer-rp 양쪽 적용, 로케일 키(agreeThis/readToBottom/stepLabel) 추가.
- 버전 0.4.0 → 0.4.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:54:10 +09:00
parent 996a7cc03e
commit 3f553f958d
5 changed files with 192 additions and 118 deletions

View File

@@ -232,76 +232,110 @@ function showAgreementError(message) {
section.querySelector('#retry').addEventListener('click', renderAgreement)
}
// 약관을 한 건씩 페이지로 보여준다. 각 약관마다 "해당 약관에 동의합니다" 체크가 있고,
// 끝까지 스크롤해 읽어야 체크할 수 있으며, [다음] 을 누르면 다음 약관 페이지가 화면
// 맨 위에서부터 나온다. 마지막 약관까지 동의하면 다음 단계(step2)로 진행한다.
function renderAgreementWithKinds(KINDS) {
var idx = 0
var accepted = {} // kind -> true (뒤로 갔다 와도 동의 상태 유지)
var cache = {} // kind -> 렌더된 HTML
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
'<div class="tabBar" id="agTabs">' +
KINDS.map(function (k, i) {
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + escapeHtml(k.id) + '">' + escapeHtml(k.tab) + '</button>'
}).join('') +
'</div>' +
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
tt('agreement.agreeAll') + '</label>' +
'<div class="formMessage" id="agMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var body = section.querySelector('#agBody')
var tabs = section.querySelectorAll('[data-ag]')
var nextBtn = section.querySelector('#next')
var accept = section.querySelector('#agAccept')
var msg = section.querySelector('#agMsg')
function renderCurrent() {
var k = KINDS[idx]
var isLast = idx === KINDS.length - 1
section.innerHTML =
'<h2>' + tt('agreement.heading') + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('agreement.stepLabel', { current: idx + 1, total: KINDS.length, label: k.tab })) + '</p>' +
'<div class="agreementBody" id="agBody" style="border-radius:10px;">' + tt('agreement.loading') + '</div>' +
'<div class="formMessage" id="agHint">' + tt('agreement.readToBottom') + '</div>' +
'<label class="toggleRow" style="margin-top:8px;"><input type="checkbox" id="agAccept" disabled /> ' +
tt('agreement.agreeThis') + '</label>' +
'<div class="formMessage" id="agMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
// 약관 본문은 한 번 받으면 캐시. 탭 전환 시 재요청하지 않는다.
var cache = {}
var body = section.querySelector('#agBody')
var accept = section.querySelector('#agAccept')
var nextBtn = section.querySelector('#next')
var msg = section.querySelector('#agMsg')
var hint = section.querySelector('#agHint')
function showKind(kind) {
if (cache[kind]) {
body.innerHTML = cache[kind]
return
// 새 약관 페이지는 화면 맨 위에서부터 보이도록 스크롤을 올린다.
if (pageHost) pageHost.scrollTop = 0
function markReadable() {
if (!accept.disabled) return
accept.disabled = false
hint.textContent = ''
}
body.textContent = tt('agreement.loading')
installerApi.getTerm(kind).then(function (res) {
if (!res.ok) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: res.message || '' }) + '</p>'
function checkScrolled() {
// 본문을 끝까지(하단 근처) 내렸으면 동의 체크를 허용한다.
if (body.scrollHeight - body.scrollTop - body.clientHeight <= 6) markReadable()
}
body.addEventListener('scroll', checkScrolled)
accept.addEventListener('change', function () {
accepted[k.id] = accept.checked
nextBtn.disabled = !accept.checked
if (accept.checked) { msg.textContent = ''; msg.classList.remove('error') }
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
var html = renderTermsMarkdown(res.content || '')
cache[kind] = html
body.innerHTML = html
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: err.message }) + '</p>'
if (isLast) renderStep2()
else { idx++; renderCurrent() }
})
section.querySelector('#back').addEventListener('click', function () {
if (idx === 0) renderStep1()
else { idx--; renderCurrent() }
})
function afterLoad() {
body.scrollTop = 0
// 이미 동의했던 약관으로 되돌아온 경우: 체크/다음 활성화 유지.
if (accepted[k.id]) {
accept.disabled = false
accept.checked = true
nextBtn.disabled = false
hint.textContent = ''
}
// 레이아웃 확정 후: 스크롤이 필요 없을 만큼 짧은 약관이면 바로 동의 허용.
setTimeout(function () {
if (body.scrollHeight - body.clientHeight <= 6) markReadable()
else checkScrolled()
}, 0)
}
if (cache[k.id]) {
body.innerHTML = cache[k.id]
afterLoad()
} else {
body.textContent = tt('agreement.loading')
installerApi.getTerm(k.id).then(function (res) {
if (!res.ok) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: res.message || '' }) + '</p>'
markReadable()
return
}
var html = renderTermsMarkdown(res.content || '')
cache[k.id] = html
body.innerHTML = html
afterLoad()
}).catch(function (err) {
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: err.message }) + '</p>'
markReadable()
})
}
}
tabs.forEach(function (b) {
b.addEventListener('click', function () {
tabs.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
showKind(b.getAttribute('data-ag'))
})
})
accept.addEventListener('change', function () {
nextBtn.disabled = !accept.checked
if (accept.checked) msg.textContent = ''
})
nextBtn.addEventListener('click', function () {
if (!accept.checked) {
msg.textContent = tt('agreement.agreeRequired')
msg.classList.add('error')
return
}
renderStep2()
})
section.querySelector('#back').addEventListener('click', renderStep1)
showKind(KINDS[0].id)
renderCurrent()
}
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.