join.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. <template>
  2. <main class="user--main">
  3. <div class="join--container">
  4. <div class="join--step--wrap">
  5. <div class="step--txt">
  6. <span class="color--blue">2 / 3</span> 정보 입<span class="color--blue">력</span>
  7. </div>
  8. <div class="step--bar">
  9. <span class="active"></span>
  10. <span class="active"></span>
  11. <span></span>
  12. </div>
  13. </div>
  14. <div class="join--step2 mt--22">
  15. <div class="title--wrap">
  16. <h2>정보 입력</h2>
  17. <p class="mt--8">파이럿존 이용을 위해 회원가입을 해주세요</p>
  18. </div>
  19. <div class="join--wrap mt--18">
  20. <!-- 아이디 -->
  21. <div class="input--wrap">
  22. <label for="join--id">아이디 <span class="required">*</span></label>
  23. <div class="input--inner--wrap">
  24. <input
  25. id="join--id"
  26. v-model="username"
  27. type="text"
  28. placeholder="아이디 입력"
  29. maxlength="20"
  30. @input="usernameChecked = false"
  31. >
  32. <button
  33. type="button"
  34. class="btn--border--blue join--btn"
  35. :disabled="checkingUsername"
  36. @click="checkUsername"
  37. >{{ checkingUsername ? '확인중...' : '중복확인' }}</button>
  38. </div>
  39. </div>
  40. <p class="input--info" :class="{ 'color--blue': usernameChecked }">
  41. {{ usernameChecked ? '✓ 사용 가능한 아이디입니다.' : '영문/숫자, 6자 이상 20자 이하' }}
  42. </p>
  43. <!-- 비밀번호 -->
  44. <div class="input--wrap mt--10 pw--input--wrap">
  45. <label for="join--pw">비밀번호 <span class="required">*</span></label>
  46. <input
  47. id="join--pw"
  48. v-model="password"
  49. :type="showPw ? 'text' : 'password'"
  50. placeholder="비밀번호 입력"
  51. maxlength="30"
  52. >
  53. <button
  54. type="button"
  55. class="pw--toggle--btn"
  56. :aria-label="showPw ? '비밀번호 숨기기' : '비밀번호 표시'"
  57. @click="showPw = !showPw"
  58. >
  59. <img v-if="showPw" src="/img/ico--pw.svg" alt="비밀번호 표시" />
  60. <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
  61. </button>
  62. </div>
  63. <p class="input--info" :style="passwordInvalid ? 'color:#e5484d;' : ''">
  64. 영문(소문자)+숫자+특수문자 조합 8자 이상
  65. </p>
  66. <!-- 비밀번호 확인 -->
  67. <div class="input--wrap mt--10 pw--input--wrap">
  68. <label for="join--pw2">비밀번호 확인 <span class="required">*</span></label>
  69. <input
  70. id="join--pw2"
  71. v-model="password2"
  72. :type="showPw2 ? 'text' : 'password'"
  73. placeholder="비밀번호 재입력"
  74. maxlength="30"
  75. >
  76. <button
  77. type="button"
  78. class="pw--toggle--btn"
  79. :aria-label="showPw2 ? '비밀번호 숨기기' : '비밀번호 표시'"
  80. @click="showPw2 = !showPw2"
  81. >
  82. <img v-if="showPw2" src="/img/ico--pw.svg" alt="비밀번호 표시" />
  83. <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
  84. </button>
  85. </div>
  86. <p v-if="password2 && password !== password2" class="input--info" style="color:#e5484d;">
  87. 비밀번호가 일치하지 않습니다.
  88. </p>
  89. <!-- 이름 -->
  90. <div class="input--wrap mt--18">
  91. <label for="join--name">이름 <span class="required">*</span></label>
  92. <input id="join--name" v-model="name" type="text" placeholder="이름 입력" maxlength="50">
  93. </div>
  94. <!-- 핸드폰 -->
  95. <div class="input--wrap mt--18">
  96. <label for="join--phone1">핸드폰 <span class="required">*</span></label>
  97. <div class="input--inner--wrap gap--4">
  98. <input id="join--phone1" v-model="phoneFront" type="text" maxlength="3">
  99. <span>-</span>
  100. <input v-model="phoneMiddle" type="text" maxlength="4" inputmode="numeric">
  101. <span>-</span>
  102. <input v-model="phoneLast" type="text" maxlength="4" inputmode="numeric">
  103. </div>
  104. </div>
  105. <!-- 닉네임 -->
  106. <div class="input--wrap mt--18">
  107. <label for="join--nickname">닉네임 <span class="required">*</span></label>
  108. <div class="input--inner--wrap">
  109. <input
  110. id="join--nickname"
  111. v-model="nickname"
  112. type="text"
  113. placeholder="닉네임 입력"
  114. maxlength="20"
  115. @input="nicknameChecked = false"
  116. >
  117. <button
  118. type="button"
  119. class="btn--border--blue join--btn"
  120. :disabled="checkingNickname"
  121. @click="checkNickname"
  122. >{{ checkingNickname ? '확인중...' : '중복확인' }}</button>
  123. </div>
  124. </div>
  125. <p v-if="nicknameChecked" class="input--info color--blue">✓ 사용 가능한 닉네임입니다.</p>
  126. <!-- 낚시 선호지역 -->
  127. <div class="input--wrap mt--18">
  128. <label>낚시 선호지역 <span class="required">*</span> (중복 선택)</label>
  129. <div class="join--bubble--wrap mt--16">
  130. <button
  131. v-for="area in allAreas"
  132. :key="area"
  133. type="button"
  134. class="area--bubble"
  135. :class="{ active: preferAreas.includes(area) }"
  136. @click="toggleArea(area)"
  137. >{{ area }}</button>
  138. </div>
  139. </div>
  140. </div>
  141. </div>
  142. </div>
  143. <div class="float--btn--wrap">
  144. <a
  145. href="#"
  146. :class="{ disabled: !canSubmit || submitting }"
  147. @click.prevent="handleSubmit"
  148. >{{ submitting ? '가입 중...' : '회원가입 완료' }}</a>
  149. </div>
  150. <AppAlertModal
  151. v-model="modal.show"
  152. :icon-type="modal.iconType"
  153. :title="modal.title"
  154. :message="modal.message"
  155. />
  156. </main>
  157. </template>
  158. <script setup>
  159. import { ref, computed, reactive } from 'vue'
  160. import { useRouter } from 'vue-router'
  161. import AppAlertModal from '~/components/AppAlertModal.vue'
  162. const router = useRouter()
  163. const { post } = useApi()
  164. // 폼 데이터
  165. const username = ref('')
  166. const password = ref('')
  167. const password2 = ref('')
  168. const name = ref('')
  169. const phoneFront = ref('010')
  170. const phoneMiddle = ref('')
  171. const phoneLast = ref('')
  172. const nickname = ref('')
  173. const preferAreas = ref([])
  174. // UI 상태
  175. const showPw = ref(false)
  176. const showPw2 = ref(false)
  177. const checkingUsername = ref(false)
  178. const checkingNickname = ref(false)
  179. const usernameChecked = ref(false)
  180. const nicknameChecked = ref(false)
  181. const submitting = ref(false)
  182. // 알림 모달 (공통)
  183. const modal = reactive({
  184. show: false,
  185. iconType: 'error',
  186. title: '',
  187. message: '',
  188. })
  189. const showAlert = (title, message, iconType = 'error') => {
  190. modal.title = title
  191. modal.message = message
  192. modal.iconType = iconType
  193. modal.show = true
  194. }
  195. // 선호지역
  196. const allAreas = ['남해', '동해', '민물', '서해', '제주']
  197. const toggleArea = (area) => {
  198. const idx = preferAreas.value.indexOf(area)
  199. if (idx === -1) preferAreas.value.push(area)
  200. else preferAreas.value.splice(idx, 1)
  201. }
  202. // 정규식
  203. const USERNAME_RE = /^[a-zA-Z0-9]{6,20}$/
  204. const PASSWORD_RE = /^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>/?]).{8,}$/
  205. // 비밀번호 조건 미충족 (입력은 됐는데 조건 안 맞음)
  206. const passwordInvalid = computed(() =>
  207. password.value !== '' && !PASSWORD_RE.test(password.value)
  208. )
  209. // 회원가입 가능 여부 (버튼 활성화)
  210. const canSubmit = computed(() =>
  211. usernameChecked.value &&
  212. nicknameChecked.value &&
  213. USERNAME_RE.test(username.value) &&
  214. PASSWORD_RE.test(password.value) &&
  215. password.value === password2.value &&
  216. name.value.trim() !== '' &&
  217. /^\d{3,4}$/.test(phoneMiddle.value) &&
  218. /^\d{4}$/.test(phoneLast.value) &&
  219. preferAreas.value.length > 0
  220. )
  221. // 아이디 중복확인
  222. const checkUsername = async () => {
  223. if (!USERNAME_RE.test(username.value)) {
  224. showAlert('아이디 형식 오류', '영문/숫자 6자 이상 20자 이하로 입력해주세요.')
  225. return
  226. }
  227. checkingUsername.value = true
  228. try {
  229. const { data, error } = await post('/users/check-username', { username: username.value })
  230. if (error || !data?.success) {
  231. showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
  232. usernameChecked.value = false
  233. return
  234. }
  235. usernameChecked.value = true
  236. showAlert('확인 완료', '사용 가능한 아이디입니다.', 'success')
  237. } catch (e) {
  238. console.error(e)
  239. showAlert('오류', '서버 오류가 발생했습니다.')
  240. } finally {
  241. checkingUsername.value = false
  242. }
  243. }
  244. // 닉네임 중복확인
  245. const checkNickname = async () => {
  246. if (nickname.value.trim().length < 2 || nickname.value.trim().length > 20) {
  247. showAlert('닉네임 형식 오류', '닉네임은 2자 이상 20자 이하로 입력해주세요.')
  248. return
  249. }
  250. checkingNickname.value = true
  251. try {
  252. const { data, error } = await post('/users/check-nickname', { nickname: nickname.value })
  253. if (error || !data?.success) {
  254. showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
  255. nicknameChecked.value = false
  256. return
  257. }
  258. nicknameChecked.value = true
  259. showAlert('확인 완료', '사용 가능한 닉네임입니다.', 'success')
  260. } catch (e) {
  261. console.error(e)
  262. showAlert('오류', '서버 오류가 발생했습니다.')
  263. } finally {
  264. checkingNickname.value = false
  265. }
  266. }
  267. // 회원가입 제출
  268. const handleSubmit = async () => {
  269. if (!canSubmit.value || submitting.value) return
  270. submitting.value = true
  271. try {
  272. const marketingAgree = sessionStorage.getItem('signup_marketing') || 'N'
  273. const payload = {
  274. username: username.value,
  275. password: password.value,
  276. name: name.value.trim(),
  277. phone: `${phoneFront.value}-${phoneMiddle.value}-${phoneLast.value}`,
  278. nickname: nickname.value.trim(),
  279. prefer_area: preferAreas.value.join(','),
  280. marketing_agree_YN: marketingAgree,
  281. }
  282. const { data, error } = await post('/users/signup', payload)
  283. if (error || !data?.success) {
  284. showAlert('회원가입 실패', error?.message || data?.message || '가입에 실패했습니다.')
  285. return
  286. }
  287. // 성공 — sessionStorage 정리 + 완료 페이지 이동
  288. sessionStorage.removeItem('signup_marketing')
  289. sessionStorage.setItem('signup_done_nickname', data.data?.nickname || nickname.value)
  290. router.push('/login/joinComplete')
  291. } catch (e) {
  292. console.error(e)
  293. showAlert('오류', '서버 오류가 발생했습니다.')
  294. } finally {
  295. submitting.value = false
  296. }
  297. }
  298. </script>