| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- <template>
- <main class="user--main">
- <div class="join--container">
- <div class="join--step--wrap">
- <div class="step--txt">
- <span class="color--blue">2 / 3</span> 정보 입<span class="color--blue">력</span>
- </div>
- <div class="step--bar">
- <span class="active"></span>
- <span class="active"></span>
- <span></span>
- </div>
- </div>
- <div class="join--step2 mt--22">
- <div class="title--wrap">
- <h2>정보 입력</h2>
- <p class="mt--8">파이럿존 이용을 위해 회원가입을 해주세요</p>
- </div>
- <div class="join--wrap mt--18">
- <!-- 아이디 -->
- <div class="input--wrap">
- <label for="join--id">아이디 <span class="required">*</span></label>
- <div class="input--inner--wrap">
- <input
- id="join--id"
- v-model="username"
- type="text"
- placeholder="아이디 입력"
- maxlength="20"
- @input="usernameChecked = false"
- >
- <button
- type="button"
- class="btn--border--blue join--btn"
- :disabled="checkingUsername"
- @click="checkUsername"
- >{{ checkingUsername ? '확인중...' : '중복확인' }}</button>
- </div>
- </div>
- <p class="input--info" :class="{ 'color--blue': usernameChecked }">
- {{ usernameChecked ? '✓ 사용 가능한 아이디입니다.' : '영문/숫자, 6자 이상 20자 이하' }}
- </p>
- <!-- 비밀번호 -->
- <div class="input--wrap mt--10 pw--input--wrap">
- <label for="join--pw">비밀번호 <span class="required">*</span></label>
- <input
- id="join--pw"
- v-model="password"
- :type="showPw ? 'text' : 'password'"
- placeholder="비밀번호 입력"
- maxlength="30"
- >
- <button
- type="button"
- class="pw--toggle--btn"
- :aria-label="showPw ? '비밀번호 숨기기' : '비밀번호 표시'"
- @click="showPw = !showPw"
- >
- <img v-if="showPw" src="/img/ico--pw.svg" alt="비밀번호 표시" />
- <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
- </button>
- </div>
- <p class="input--info" :style="passwordInvalid ? 'color:#e5484d;' : ''">
- 영문(소문자)+숫자+특수문자 조합 8자 이상
- </p>
- <!-- 비밀번호 확인 -->
- <div class="input--wrap mt--10 pw--input--wrap">
- <label for="join--pw2">비밀번호 확인 <span class="required">*</span></label>
- <input
- id="join--pw2"
- v-model="password2"
- :type="showPw2 ? 'text' : 'password'"
- placeholder="비밀번호 재입력"
- maxlength="30"
- >
- <button
- type="button"
- class="pw--toggle--btn"
- :aria-label="showPw2 ? '비밀번호 숨기기' : '비밀번호 표시'"
- @click="showPw2 = !showPw2"
- >
- <img v-if="showPw2" src="/img/ico--pw.svg" alt="비밀번호 표시" />
- <img v-else src="/img/ico--pw--off.svg" alt="비밀번호 숨김" />
- </button>
- </div>
- <p v-if="password2 && password !== password2" class="input--info" style="color:#e5484d;">
- 비밀번호가 일치하지 않습니다.
- </p>
- <!-- 이름 -->
- <div class="input--wrap mt--18">
- <label for="join--name">이름 <span class="required">*</span></label>
- <input id="join--name" v-model="name" type="text" placeholder="이름 입력" maxlength="50">
- </div>
- <!-- 핸드폰 -->
- <div class="input--wrap mt--18">
- <label for="join--phone1">핸드폰 <span class="required">*</span></label>
- <div class="input--inner--wrap gap--4">
- <input id="join--phone1" v-model="phoneFront" type="text" maxlength="3">
- <span>-</span>
- <input v-model="phoneMiddle" type="text" maxlength="4" inputmode="numeric">
- <span>-</span>
- <input v-model="phoneLast" type="text" maxlength="4" inputmode="numeric">
- </div>
- </div>
- <!-- 닉네임 -->
- <div class="input--wrap mt--18">
- <label for="join--nickname">닉네임 <span class="required">*</span></label>
- <div class="input--inner--wrap">
- <input
- id="join--nickname"
- v-model="nickname"
- type="text"
- placeholder="닉네임 입력"
- maxlength="20"
- @input="nicknameChecked = false"
- >
- <button
- type="button"
- class="btn--border--blue join--btn"
- :disabled="checkingNickname"
- @click="checkNickname"
- >{{ checkingNickname ? '확인중...' : '중복확인' }}</button>
- </div>
- </div>
- <p v-if="nicknameChecked" class="input--info color--blue">✓ 사용 가능한 닉네임입니다.</p>
- <!-- 낚시 선호지역 -->
- <div class="input--wrap mt--18">
- <label>낚시 선호지역 <span class="required">*</span> (중복 선택)</label>
- <div class="join--bubble--wrap mt--16">
- <button
- v-for="area in allAreas"
- :key="area"
- type="button"
- class="area--bubble"
- :class="{ active: preferAreas.includes(area) }"
- @click="toggleArea(area)"
- >{{ area }}</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div class="float--btn--wrap">
- <a
- href="#"
- :class="{ disabled: !canSubmit || submitting }"
- @click.prevent="handleSubmit"
- >{{ submitting ? '가입 중...' : '회원가입 완료' }}</a>
- </div>
- <AppAlertModal
- v-model="modal.show"
- :icon-type="modal.iconType"
- :title="modal.title"
- :message="modal.message"
- />
- </main>
- </template>
- <script setup>
- import { ref, computed, reactive } from 'vue'
- import { useRouter } from 'vue-router'
- import AppAlertModal from '~/components/AppAlertModal.vue'
- const router = useRouter()
- const { post } = useApi()
- // 폼 데이터
- const username = ref('')
- const password = ref('')
- const password2 = ref('')
- const name = ref('')
- const phoneFront = ref('010')
- const phoneMiddle = ref('')
- const phoneLast = ref('')
- const nickname = ref('')
- const preferAreas = ref([])
- // UI 상태
- const showPw = ref(false)
- const showPw2 = ref(false)
- const checkingUsername = ref(false)
- const checkingNickname = ref(false)
- const usernameChecked = ref(false)
- const nicknameChecked = ref(false)
- const submitting = ref(false)
- // 알림 모달 (공통)
- const modal = reactive({
- show: false,
- iconType: 'error',
- title: '',
- message: '',
- })
- const showAlert = (title, message, iconType = 'error') => {
- modal.title = title
- modal.message = message
- modal.iconType = iconType
- modal.show = true
- }
- // 선호지역
- const allAreas = ['남해', '동해', '민물', '서해', '제주']
- const toggleArea = (area) => {
- const idx = preferAreas.value.indexOf(area)
- if (idx === -1) preferAreas.value.push(area)
- else preferAreas.value.splice(idx, 1)
- }
- // 정규식
- const USERNAME_RE = /^[a-zA-Z0-9]{6,20}$/
- const PASSWORD_RE = /^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>/?]).{8,}$/
- // 비밀번호 조건 미충족 (입력은 됐는데 조건 안 맞음)
- const passwordInvalid = computed(() =>
- password.value !== '' && !PASSWORD_RE.test(password.value)
- )
- // 회원가입 가능 여부 (버튼 활성화)
- const canSubmit = computed(() =>
- usernameChecked.value &&
- nicknameChecked.value &&
- USERNAME_RE.test(username.value) &&
- PASSWORD_RE.test(password.value) &&
- password.value === password2.value &&
- name.value.trim() !== '' &&
- /^\d{3,4}$/.test(phoneMiddle.value) &&
- /^\d{4}$/.test(phoneLast.value) &&
- preferAreas.value.length > 0
- )
- // 아이디 중복확인
- const checkUsername = async () => {
- if (!USERNAME_RE.test(username.value)) {
- showAlert('아이디 형식 오류', '영문/숫자 6자 이상 20자 이하로 입력해주세요.')
- return
- }
- checkingUsername.value = true
- try {
- const { data, error } = await post('/users/check-username', { username: username.value })
- if (error || !data?.success) {
- showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
- usernameChecked.value = false
- return
- }
- usernameChecked.value = true
- showAlert('확인 완료', '사용 가능한 아이디입니다.', 'success')
- } catch (e) {
- console.error(e)
- showAlert('오류', '서버 오류가 발생했습니다.')
- } finally {
- checkingUsername.value = false
- }
- }
- // 닉네임 중복확인
- const checkNickname = async () => {
- if (nickname.value.trim().length < 2 || nickname.value.trim().length > 20) {
- showAlert('닉네임 형식 오류', '닉네임은 2자 이상 20자 이하로 입력해주세요.')
- return
- }
- checkingNickname.value = true
- try {
- const { data, error } = await post('/users/check-nickname', { nickname: nickname.value })
- if (error || !data?.success) {
- showAlert('중복확인 실패', error?.message || data?.message || '확인에 실패했습니다.')
- nicknameChecked.value = false
- return
- }
- nicknameChecked.value = true
- showAlert('확인 완료', '사용 가능한 닉네임입니다.', 'success')
- } catch (e) {
- console.error(e)
- showAlert('오류', '서버 오류가 발생했습니다.')
- } finally {
- checkingNickname.value = false
- }
- }
- // 회원가입 제출
- const handleSubmit = async () => {
- if (!canSubmit.value || submitting.value) return
- submitting.value = true
- try {
- const marketingAgree = sessionStorage.getItem('signup_marketing') || 'N'
- const payload = {
- username: username.value,
- password: password.value,
- name: name.value.trim(),
- phone: `${phoneFront.value}-${phoneMiddle.value}-${phoneLast.value}`,
- nickname: nickname.value.trim(),
- prefer_area: preferAreas.value.join(','),
- marketing_agree_YN: marketingAgree,
- }
- const { data, error } = await post('/users/signup', payload)
- if (error || !data?.success) {
- showAlert('회원가입 실패', error?.message || data?.message || '가입에 실패했습니다.')
- return
- }
- // 성공 — sessionStorage 정리 + 완료 페이지 이동
- sessionStorage.removeItem('signup_marketing')
- sessionStorage.setItem('signup_done_nickname', data.data?.nickname || nickname.value)
- router.push('/login/joinComplete')
- } catch (e) {
- console.error(e)
- showAlert('오류', '서버 오류가 발생했습니다.')
- } finally {
- submitting.value = false
- }
- }
- </script>
|