Files
sb-front/src/components/SignupModal.vue
T
ByungCheol c5e4c2dad7 feat: 웹 안내페이지(앱전용)·회원가입 제한/봇차단·카드 인식 보정
- 웹(브라우저)은 안내 페이지만, 실제 이용은 앱(Capacitor). 개발모드는 예외
- 회원가입: 약관동의, 관리자 제한 토글, 진입부터 차단, 허니팟
- 카드 알림: pending 건 계좌종류 '카드' 기본선택(현금 X), 확인실패 시 HTTP 상태 표시
- 네이티브 알림필터: '카드' 키워드 제거+광고성 표현 차단
- docs/release-2026-06-06.md 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:16:50 +09:00

327 lines
9.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { reactive, ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import { TERMS_OF_SERVICE, PRIVACY_POLICY } from '@/constants/terms'
const auth = useAuthStore()
const ui = useUiStore()
// website = 허니팟(숨김 필드, 봇이 채우면 서버에서 차단)
const form = reactive({ loginId: '', password: '', passwordConfirm: '', name: '', email: '', website: '' })
const loading = ref(false)
const error = ref(null)
// 약관 동의 상태
const agree = reactive({ terms: false, privacy: false })
const openDoc = ref('') // 'terms' | 'privacy' | '' (열린 전문)
const allAgreed = computed({
get: () => agree.terms && agree.privacy,
set: (v) => {
agree.terms = v
agree.privacy = v
},
})
function toggleDoc(key) {
openDoc.value = openDoc.value === key ? '' : key
}
// 팝업이 열릴 때마다 입력값 초기화
watch(
() => ui.signupOpen,
(open) => {
if (open) {
Object.assign(form, { loginId: '', password: '', passwordConfirm: '', name: '', email: '', website: '' })
agree.terms = false
agree.privacy = false
openDoc.value = ''
error.value = null
}
},
)
async function handleSignup() {
error.value = null
if (!ui.signupEnabled) {
error.value = '현재 회원가입이 제한되어 있습니다.'
return
}
if (!form.loginId || !form.password || !form.name) {
error.value = '아이디, 비밀번호, 이름은 필수입니다.'
return
}
if (form.password.length < 8) {
error.value = '비밀번호는 8자 이상이어야 합니다.'
return
}
if (form.password !== form.passwordConfirm) {
error.value = '비밀번호가 일치하지 않습니다.'
return
}
if (!agree.terms || !agree.privacy) {
error.value = '필수 약관에 동의해 주세요.'
return
}
loading.value = true
try {
await auth.signup({
loginId: form.loginId,
password: form.password,
name: form.name,
email: form.email || null,
website: form.website, // 허니팟(정상 사용자는 빈 값)
})
alert('회원가입이 완료되었습니다. 로그인해 주세요.')
ui.openLogin() // 회원가입 닫고 로그인 팝업으로 전환
} catch (e) {
error.value = e.response?.data?.message || '회원가입에 실패했습니다.'
} finally {
loading.value = false
}
}
function onKeydown(e) {
if (e.key === 'Escape' && ui.signupOpen) ui.closeSignup()
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="ui.signupOpen" class="modal-backdrop" @click.self="ui.closeSignup()">
<div class="modal" role="dialog" aria-modal="true" aria-label="회원가입">
<button class="close" type="button" aria-label="닫기" @click="ui.closeSignup()">×</button>
<h2>회원가입</h2>
<!-- 회원가입 제한 : 대신 안내 -->
<div v-if="!ui.signupEnabled" class="signup-blocked">
<p class="blk-title">🔒 현재 회원가입이 제한되어 있습니다.</p>
<p class="blk-desc">관리자 설정에 따라 신규 회원가입을 받지 않고 있습니다.</p>
</div>
<form v-else class="form" @submit.prevent="handleSignup">
<input v-model="form.loginId" type="text" placeholder="아이디 (4~50자)" autocomplete="username" :disabled="loading" />
<input v-model="form.password" type="password" placeholder="비밀번호 (8자 이상)" autocomplete="new-password" :disabled="loading" />
<input v-model="form.passwordConfirm" type="password" placeholder="비밀번호 확인" autocomplete="new-password" :disabled="loading" />
<input v-model="form.name" type="text" placeholder="이름" :disabled="loading" />
<input v-model="form.email" type="email" placeholder="이메일 (선택)" autocomplete="email" :disabled="loading" />
<!-- 허니팟: 사람에겐 숨김, 봇이 채우면 차단 -->
<input v-model="form.website" type="text" name="website" class="hp-field" tabindex="-1" autocomplete="off" aria-hidden="true" />
<!-- 약관 동의 -->
<div class="agree">
<label class="agree-all">
<input v-model="allAgreed" type="checkbox" :disabled="loading" />
<span>약관에 모두 동의합니다.</span>
</label>
<div class="agree-row">
<label class="agree-item">
<input v-model="agree.terms" type="checkbox" :disabled="loading" />
<span><b class="req">[필수]</b> 이용약관 동의</span>
</label>
<button type="button" class="doc-toggle" @click="toggleDoc('terms')">
{{ openDoc === 'terms' ? '닫기' : '보기' }}
</button>
</div>
<pre v-if="openDoc === 'terms'" class="doc-box">{{ TERMS_OF_SERVICE }}</pre>
<div class="agree-row">
<label class="agree-item">
<input v-model="agree.privacy" type="checkbox" :disabled="loading" />
<span><b class="req">[필수]</b> 개인정보 수집·이용 동의</span>
</label>
<button type="button" class="doc-toggle" @click="toggleDoc('privacy')">
{{ openDoc === 'privacy' ? '닫기' : '보기' }}
</button>
</div>
<pre v-if="openDoc === 'privacy'" class="doc-box">{{ PRIVACY_POLICY }}</pre>
</div>
<button class="submit" type="submit" :disabled="loading">{{ loading ? '처리 중...' : '회원가입' }}</button>
</form>
<p v-if="error" class="msg error">{{ error }}</p>
<p class="links">
이미 계정이 있으신가요?
<a href="#" @click.prevent="ui.openLogin()">로그인</a>
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
max-height: 90vh;
overflow-y: auto;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
h2 {
margin-bottom: 1.25rem;
font-size: 1.3rem;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form input {
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.submit {
padding: 0.6rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 약관 동의 */
.agree {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.6rem 0.2rem 0.2rem;
border-top: 1px solid var(--color-border);
margin-top: 0.1rem;
}
.agree-all {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
font-weight: 600;
}
.agree-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.agree-item {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
cursor: pointer;
}
.agree-item input {
flex-shrink: 0;
}
.req {
color: hsla(160, 100%, 37%, 1);
}
.doc-toggle {
flex-shrink: 0;
padding: 0.15rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-size: 0.78rem;
cursor: pointer;
}
.doc-box {
max-height: 180px;
overflow-y: auto;
margin: 0;
padding: 0.6rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
font-family: inherit;
font-size: 0.76rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* 허니팟: 화면 밖으로 숨김(스크린리더/봇 인식 위해 display:none 대신 off-screen) */
.hp-field {
position: absolute !important;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.signup-blocked {
text-align: center;
padding: 1.5rem 0.5rem 0.5rem;
}
.blk-title {
font-size: 1rem;
font-weight: 700;
color: #c0392b;
}
.blk-desc {
margin-top: 0.5rem;
font-size: 0.85rem;
opacity: 0.75;
}
.links {
margin-top: 1.25rem;
text-align: center;
font-size: 0.9rem;
}
.msg {
margin: 0.75rem 0 0;
}
.msg.error {
color: #c0392b;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>