251212_login

This commit is contained in:
2025-12-12 03:38:56 +09:00
parent 1eba2a0a49
commit c9f19e28ba
6 changed files with 6662 additions and 2 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,718 @@
<template>
<div
class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-12"
>
<div
class="w-full max-w-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm px-6 py-8 sm:px-8"
>
<!-- Title -->
<h1 class="text-center text-2xl font-bold text-gray-900 dark:text-white">
회원가입
</h1>
<!-- STEP 1: 약관 동의 -->
<section v-if="!termsProceed" class="mt-8 space-y-6">
<!-- 이용약관 -->
<div
class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 max-h-60 overflow-y-auto p-4"
>
<app-termsof-use />
</div>
<!-- 이용약관 동의 -->
<div class="space-y-3 text-sm text-gray-800 dark:text-gray-200">
<p>
<strong class="font-semibold text-red-500">[필수]</strong>
&nbsp;위의 이용약관에 동의하십니까?
</p>
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms1"
value="true"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span></span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms1"
value="false"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span>아니오</span>
</label>
</div>
</div>
<!-- 개인정보 수집/이용 안내 -->
<div
class="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900"
>
<table
class="min-w-full text-xs sm:text-sm text-gray-800 dark:text-gray-200"
>
<thead class="bg-gray-100 dark:bg-gray-800/70">
<tr class="text-left">
<th class="px-3 py-2">구분</th>
<th class="px-3 py-2">수집/이용 항목</th>
<th class="px-3 py-2">수집/이용 목적</th>
<th class="px-3 py-2">보유기간</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="px-3 py-2">필수</td>
<td class="px-3 py-2">
성명, 아이디, 비밀번호, 휴대폰번호, 이메일, 회원유형
</td>
<td class="px-3 py-2" rowspan="2">
회원관리 서비스 제공 (공유 오피스·백패킹 )
</td>
<td class="px-3 py-2" rowspan="2">
탈퇴 즉시 파기, 관련 법령에 따른 보관 (최대 5)
</td>
</tr>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="px-3 py-2">선택</td>
<td class="px-3 py-2">회원 프로필 사진</td>
</tr>
</tbody>
</table>
</div>
<div
class="space-y-3 text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<p>
위의 개인정보 수집·이용에 대한 동의를 거부할 권리가 있습니다. 다만,
동의하지 않을 경우 일부 서비스 이용에 제한이 있을 있습니다.
</p>
<p>
<strong class="font-semibold text-red-500">[필수]</strong>
&nbsp;위와 같이 개인정보를 수집·이용하는 동의하십니까?
</p>
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms2"
value="true"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span></span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="radio"
v-model="agreedToTerms2"
value="false"
class="h-4 w-4 text-black focus:ring-black border-gray-300"
/>
<span>아니오</span>
</label>
</div>
</div>
<!-- 다음 버튼 -->
<button
type="button"
@click="termsProceed = true"
:disabled="!(agreedToTerms1 === 'true' && agreedToTerms2 === 'true')"
class="mt-4 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-400 cursor-not-allowed': !(
agreedToTerms1 === 'true' && agreedToTerms2 === 'true'
),
'bg-black hover:bg-gray-800':
agreedToTerms1 === 'true' && agreedToTerms2 === 'true',
}"
>
다음
</button>
</section>
<!-- STEP 2: 회원정보 입력 -->
<section v-else class="mt-8">
<!-- Alert -->
<div
v-if="reg_show_alert"
:class="[
'mb-4 text-center text-sm font-semibold text-white rounded-md px-3 py-2',
reg_alert_variant,
]"
>
{{ reg_alert_msg }}
</div>
<vee-form
:validation-schema="schema"
:initial-values="userData"
@submit="handleSubmit"
class="space-y-4"
>
<!-- 이메일 -->
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-sm font-medium text-gray-900 dark:text-white">
이메일
</label>
<button
type="button"
@click="checkEmail"
:disabled="!abletoCheck || emailChecked"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium border transition"
:class="{
'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed':
!abletoCheck && !emailChecked,
'bg-yellow-100 text-yellow-700 border-yellow-400':
abletoCheck && !emailChecked,
'bg-green-100 text-green-700 border-green-500': emailChecked,
}"
>
<div v-if="emailState.checking">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
<svg
v-else
class="h-1.5 w-1.5"
:class="{
'fill-gray-300': !abletoCheck && !emailChecked,
'fill-yellow-500': abletoCheck && !emailChecked,
'fill-green-500': emailChecked,
}"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx="3" cy="3" r="3" />
</svg>
<span>중복 확인</span>
</button>
</div>
<vee-field
v-model="emailInput"
type="email"
name="email"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="example@normadbobu.com"
/>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ emailMessage }}
</p>
<ErrorMessage class="mt-1 text-xs text-red-600" name="email" />
</div>
<!-- 이름 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
이름
</label>
<vee-field
type="text"
name="name"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="이름을 입력하세요"
/>
<ErrorMessage class="mt-1 text-xs text-red-600" name="name" />
</div>
<!-- 비밀번호 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호
</label>
<vee-field
name="password"
:bails="false"
v-slot="{ field, errors }"
>
<input
v-bind="field"
type="password"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="비밀번호를 입력하세요"
/>
<div
v-for="error in errors"
:key="error"
class="mt-1 text-xs text-red-600"
>
{{ error }}
</div>
</vee-field>
</div>
<!-- 비밀번호 확인 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호 확인
</label>
<vee-field
type="password"
name="confirm_password"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
placeholder="비밀번호를 다시 입력하세요"
/>
<ErrorMessage
class="mt-1 text-xs text-red-600"
name="confirm_password"
/>
</div>
<!-- 회원 유형 -->
<div>
<label
class="mb-1.5 block text-sm font-medium text-gray-900 dark:text-white"
>
회원 유형
</label>
<vee-field
as="select"
name="membership"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-black"
>
<option value="일반회원">일반회원</option>
<option value="노마드 워커">노마드 워커</option>
<option value="로컬 주민">로컬 주민</option>
<option value="기업/단체">기업/단체</option>
</vee-field>
<ErrorMessage class="mt-1 text-xs text-red-600" name="membership" />
</div>
<!-- reCAPTCHA -->
<div ref="recaptchaWrapperRef" class="mt-2">
<div id="recaptcha-container"></div>
</div>
<!-- 휴대폰 번호 -->
<div>
<label
class="flex justify-between mb-1.5 text-sm font-medium text-gray-900 dark:text-white"
>
<span>휴대폰 번호</span>
<button
v-if="startPhoneRegister"
type="button"
@click="resetPhonenumber"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
변경하기
</button>
</label>
<input
type="tel"
v-model="rawPhoneNumber"
placeholder="010-1234-5678"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
:disabled="startPhoneRegister"
:class="{ 'bg-gray-100 dark:bg-gray-800': startPhoneRegister }"
/>
</div>
<!-- 인증번호 입력 -->
<div v-if="startPhoneRegister">
<label
class="flex justify-between mb-1.5 text-sm font-medium text-gray-900 dark:text-white"
>
<span>인증번호</span>
<button
v-if="canResend"
type="button"
@click="resendSms"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
다시 전송하기
</button>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
</label>
<input
v-model="smsCode"
placeholder="인증번호를 입력하세요"
class="block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
/>
</div>
<!-- 버튼 영역 -->
<!-- 1) 이메일 미확인 -->
<button
v-if="!emailChecked"
type="button"
disabled
class="mt-6 flex w-full justify-center rounded-md bg-gray-400 px-4 py-3 text-sm font-semibold text-white shadow-sm cursor-not-allowed"
>
이메일 중복 확인을 완료해주세요
</button>
<!-- 2) 전화번호 인증 시작 버튼 -->
<button
v-if="!startPhoneRegister && emailChecked"
type="button"
@click="startPhoneNumberVerification"
:disabled="!isValidPhoneNumber"
class="mt-6 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-400 cursor-not-allowed': !isValidPhoneNumber,
'bg-black hover:bg-gray-800': isValidPhoneNumber,
}"
>
<span v-if="!isValidPhoneNumber">휴대폰 번호를 확인해주세요</span>
<span v-else>인증번호 전송하기</span>
</button>
<!-- 3) 최종 가입 버튼 -->
<button
v-if="startPhoneRegister"
type="submit"
:disabled="reg_in_submission || !emailChecked"
class="mt-6 flex w-full justify-center rounded-md px-4 py-3 text-sm font-semibold text-white shadow-sm transition"
:class="{
'bg-gray-500 cursor-not-allowed':
reg_in_submission || !emailChecked,
'bg-black hover:bg-gray-800': !reg_in_submission && emailChecked,
}"
>
<div v-if="onSmsfunction">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</div>
<span v-else>가입하기</span>
</button>
</vee-form>
<!-- Loading overlay -->
<app-loading-overlay
:isLoading="reg_in_submission"
:loadingMessage="loadingMessage"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive, toRefs, computed } from 'vue';
import { useRouter } from 'vue-router';
import type { ConfirmationResult } from 'firebase/auth';
import useUserStore from '@/stores/user';
import { checkEmailDuplicate } from '@/utils/firebaseUtils';
import AppTermsofUse from '~/components/TermsofUse.vue';
import AppLoadingOverlay from '~/components/LoadingOverlay.vue';
import {
auth,
phoneNumberAuth,
createRecaptchaVerifier,
} from '@/utils/firebaseUtils';
const router = useRouter();
const userStore = useUserStore();
const { signInWithPhoneNumber } = phoneNumberAuth;
const emits = defineEmits(['register-success']);
/* ---------- STEP control ---------- */
const termsProceed = ref(false);
const agreedToTerms1 = ref<'true' | 'false' | ''>('');
const agreedToTerms2 = ref<'true' | 'false' | ''>('');
/* ---------- VeeValidate schema ---------- */
const schema = {
name: 'required|min:2|max:10',
email: 'required|min:2|max:100|email',
password: 'required|min:9|max:100|excluded:password',
confirm_password: 'passwords_mismatch:@password',
membership: 'required|membership_excluded',
};
interface RegisterValues {
email: string;
password: string;
confirm_password: string;
name: string;
membership: string;
isActive: boolean;
profile_img: string;
created: any;
uid: string;
phone: string;
}
const userData = ref({
membership: '일반회원',
});
/* ---------- Email duplication check ---------- */
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
const emailState = reactive({
emailInput: '',
abletoCheck: false,
emailChecked: false,
emailMessage: '',
checking: false,
});
const { emailInput, abletoCheck, emailChecked, emailMessage } =
toRefs(emailState);
const checkEmail = async () => {
emailState.checking = true;
if (!emailState.abletoCheck) {
emailState.checking = false;
return;
}
const result = await checkEmailDuplicate(emailState.emailInput);
switch (result.status) {
case 'exists':
emailState.emailMessage = '이미 존재하는 이메일입니다.';
emailState.checking = false;
break;
case 'available':
emailState.emailMessage = '가입 가능한 이메일입니다.';
emailState.emailChecked = true;
emailState.checking = false;
break;
case 'error':
default:
emailState.abletoCheck = false;
emailState.emailMessage = result.message;
emailState.checking = false;
break;
}
};
watch(
() => emailState.emailInput,
(newValue) => {
emailState.abletoCheck = false;
emailState.emailChecked = false;
emailState.emailMessage = '';
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
emailState.abletoCheck = emailRegex.test(newValue);
}
);
/* ---------- Phone verification ---------- */
const firebaseAuth = auth;
const rawPhoneNumber = ref('');
const phoneNumber = ref('');
const smsCode = ref('');
const startPhoneRegister = ref(false);
const phoneVerified = ref(false);
const onSmsfunction = ref(false);
const canResend = ref(true);
const cooldownTime = ref(5);
const recaptchaWrapperRef = ref<HTMLElement | null>(null);
const isValidPhoneNumber = computed(() => {
if (typeof rawPhoneNumber.value !== 'string') return false;
const cleaned = rawPhoneNumber.value.replace(/-/g, '');
return (
(cleaned.startsWith('010') || cleaned.startsWith('070')) &&
cleaned.length === 11
);
});
const resetPhonenumber = () => {
rawPhoneNumber.value = '';
startPhoneRegister.value = false;
recaptchaVerifier = null;
confirmationResult = null;
};
const processedPhoneNumber = computed(() => {
if (typeof rawPhoneNumber.value !== 'string') return '';
let cleaned = rawPhoneNumber.value.replace(/-/g, '');
if (cleaned.startsWith('0')) {
cleaned = '+82' + cleaned.substring(1);
}
return cleaned;
});
let recaptchaVerifier: any | null = null;
let confirmationResult: ConfirmationResult | null = null;
const startPhoneNumberVerification = async () => {
try {
phoneNumber.value = processedPhoneNumber.value;
} catch (error) {
console.error(error);
}
try {
startPhoneRegister.value = true;
if (recaptchaVerifier) {
try {
await recaptchaVerifier.verify();
} catch (recaptchaError) {
console.error(
'Previous reCAPTCHA verification failed:',
recaptchaError
);
return;
}
recaptchaVerifier.clear();
}
if (recaptchaWrapperRef.value) {
recaptchaWrapperRef.value.innerHTML =
'<div id="recaptcha-container"></div>';
} else {
console.error('recaptchaWrapperRef is null');
return;
}
recaptchaVerifier = createRecaptchaVerifier('recaptcha-container');
try {
await recaptchaVerifier.verify();
} catch (recaptchaError) {
console.error('Error during reCAPTCHA verification:', recaptchaError);
throw recaptchaError;
}
confirmationResult = await signInWithPhoneNumber(
firebaseAuth,
phoneNumber.value,
recaptchaVerifier
);
} catch (error) {
console.error('Error during phone number verification:', error);
}
};
const resendSms = async () => {
if (!confirmationResult) {
console.error('사이트에 문제가 있습니다, 관리자에게 문의하십시오.');
onSmsfunction.value = false;
return;
}
try {
canResend.value = false;
if (recaptchaVerifier) {
recaptchaVerifier.clear();
if (recaptchaWrapperRef.value) {
recaptchaWrapperRef.value.innerHTML =
'<div id="recaptcha-container"></div>';
} else {
console.error('recaptchaWrapperRef is null');
return;
}
}
recaptchaVerifier = createRecaptchaVerifier('recaptcha-container');
confirmationResult = await signInWithPhoneNumber(
firebaseAuth,
phoneNumber.value,
recaptchaVerifier
);
let remaining = cooldownTime.value;
const timer = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(timer);
canResend.value = true;
}
}, 1000);
} catch (error) {
console.error('Error during SMS resend:', error);
canResend.value = true;
}
};
const verifySmsCode = async (): Promise<string | null> => {
try {
onSmsfunction.value = true;
if (confirmationResult) {
const result = await confirmationResult.confirm(smsCode.value);
phoneVerified.value = true;
await firebaseAuth.signOut();
onSmsfunction.value = false;
return result.user?.uid || null;
} else {
console.error('confirmationResult is null.');
onSmsfunction.value = false;
return null;
}
} catch (error) {
console.error('Error during SMS code verification:', error);
onSmsfunction.value = false;
return null;
}
};
/* ---------- Registration submit ---------- */
const reg_in_submission = ref(false);
const reg_show_alert = ref(false);
const reg_alert_variant = ref('bg-blue-500');
const reg_alert_msg = ref('계정 생성 중입니다. 잠시만 기다려주세요!');
const handleSubmit = async (_event: Event, values: Record<string, any>) => {
reg_show_alert.value = true;
reg_in_submission.value = true;
reg_alert_variant.value = 'bg-blue-500';
reg_alert_msg.value = '계정 생성 중입니다. 잠시만 기다려주세요!';
try {
const userUID = await verifySmsCode();
try {
if (userUID) {
await visitorRegister({
...values.controlledValues,
uid: userUID,
phone: phoneNumber.value,
});
}
} catch (error) {
console.error(error);
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-red-500';
reg_alert_msg.value = '계정 생성 실패!';
return;
}
} catch (error) {
console.error('error Creating ID', error);
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-red-500';
reg_alert_msg.value = '계정 생성 실패!';
return;
}
reg_in_submission.value = false;
reg_alert_variant.value = 'bg-green-500';
reg_alert_msg.value = '성공적으로 계정이 생성되었습니다!';
emits('register-success');
router.push('/');
};
async function visitorRegister(values: RegisterValues) {
try {
const { confirm_password, ...dataToSubmit } = values;
await userStore.visitorRegister(dataToSubmit);
} catch (error) {
console.log('visitorRegisterError', error);
}
}
</script>
<style scoped>
/* Chrome, Safari, Edge, Opera: remove number input arrows if any */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div
class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4 py-12"
>
<div
class="w-full max-w-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-sm px-6 py-8 sm:px-8"
>
<!-- Title -->
<h1 class="text-center text-2xl font-bold text-gray-900 dark:text-white">
로그인
</h1>
<!-- Social logins -->
<div class="mt-8 space-y-3">
<!-- Google -->
<button
type="button"
@click="handleSocialLogin('google')"
class="w-full flex items-center gap-3 rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-white"
>
<!-- Placeholder icon: replace with real Google logo later -->
<font-awesome-icon
:icon="['fab', 'google']"
class="text-red-500 text-lg"
/>
</div>
<span class="flex-1 text-center">Google로 로그인하기</span>
</button>
<!-- Kakao -->
<button
type="button"
@click="handleSocialLogin('kakao')"
class="w-full flex items-center gap-3 rounded-md border border-yellow-400 bg-[#FEE500] px-3 py-2.5 text-sm font-medium text-gray-900 hover:bg-[#FDE64B] transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-black/5"
>
<!-- Placeholder icon: Kakao logo image or icon later -->
<img
v-if="socialImages.kakao"
:src="socialImages.kakao"
alt="Kakao"
class="h-5 w-5 object-contain"
/>
</div>
<span class="flex-1 text-center">Kakao로 로그인하기</span>
</button>
<!-- Naver -->
<button
type="button"
@click="handleSocialLogin('naver')"
class="w-full flex items-center gap-3 rounded-md border border-green-600 bg-white dark:bg-gray-900 px-3 py-2.5 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-green-50 dark:hover:bg-gray-800 transition"
>
<div
class="flex items-center justify-center h-6 w-6 rounded-full bg-green-600"
>
<img
v-if="socialImages.naver"
:src="socialImages.naver"
alt="Naver"
class="h-4 w-4 object-contain"
/>
</div>
<span class="flex-1 text-center">Naver로 로그인하기</span>
</button>
</div>
<!-- separator -->
<div class="mt-6 flex items-center">
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
<span class="mx-3 text-xs text-gray-400 dark:text-gray-500">또는</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- ID / Email + Password form -->
<form class="mt-6 space-y-4" @submit.prevent="handleSubmit">
<!-- 아이디 또는 이메일 -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-900 dark:text-white"
>
아이디 또는 이메일
</label>
<div class="mt-2">
<input
v-model="email"
type="text"
name="email"
id="email"
autocomplete="email"
required
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-black"
placeholder="example@normadbobu.com"
/>
</div>
</div>
<!-- 비밀번호 -->
<div>
<label
for="password"
class="block text-sm font-medium text-gray-900 dark:text-white"
>
비밀번호
</label>
<div class="mt-2">
<input
v-model="password"
type="password"
name="password"
id="password"
autocomplete="current-password"
required
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-2 text-sm text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-black"
placeholder="●●●●●●●●"
/>
</div>
</div>
<!-- 로그인 상태 유지 -->
<div class="flex items-center justify-between">
<label
class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<input
v-model="rememberMe"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span>기억하기</span>
</label>
</div>
<!-- Alert -->
<div
v-if="login_show_alert"
:class="[
'text-white text-center text-sm font-semibold rounded-md p-3',
login_alert_variant,
]"
>
{{ login_alert_msg }}
</div>
<!-- 로그인 button -->
<button
type="submit"
:disabled="login_in_submission"
class="mt-2 flex w-full justify-center rounded-md bg-black px-3 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black disabled:opacity-60 disabled:cursor-not-allowed"
>
로그인
</button>
</form>
<!-- 아래 링크들 -->
<div
class="mt-4 flex items-center justify-between text-xs sm:text-sm text-gray-700 dark:text-gray-300"
>
<NuxtLink to="/register" class="hover:underline"> 회원가입 </NuxtLink>
<NuxtLink to="/find-account" class="hover:underline">
아이디 · 비밀번호 찾기
</NuxtLink>
</div>
<!-- small separator -->
<p class="mt-6 text-center text-xs text-gray-400 dark:text-gray-500">
또는
</p>
<!-- 비회원 예약 주문 조회 -->
<button
type="button"
class="mt-3 w-full rounded-md bg-slate-400 px-3 py-2.5 text-sm font-semibold text-white hover:bg-slate-500 transition"
>
비회원 예약 주문 조회
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from '#imports';
import useUserStore from '@/stores/user';
import { SOCIAL_IMAGES } from '@/data/assets';
import type { LoginValues } from '@/types';
const userStore = useUserStore();
const router = useRouter();
const email = ref('');
const password = ref('');
const rememberMe = ref(false); // currently not used in logic, placeholder for future
const login_in_submission = ref(false);
const login_show_alert = ref(false);
const login_alert_variant = ref('bg-blue-500');
const login_alert_msg = ref('로그인 중입니다. 잠시만 기다려주세요!');
const socialImages = SOCIAL_IMAGES;
// placeholder handler for future social login wiring
const handleSocialLogin = (provider: 'google' | 'kakao' | 'naver') => {
console.log(`Social login clicked: ${provider}`);
// TODO: implement real social login (Firebase Auth or other)
};
const handleSubmit = async () => {
const values: LoginValues = {
email: email.value,
password: password.value,
};
login_in_submission.value = true;
login_show_alert.value = true;
login_alert_variant.value = 'bg-blue-500';
login_alert_msg.value = '로그인 중입니다. 잠시만 기다려주세요!';
// 도메인 자동 보정: @normadbobu.com
if (!values.email.includes('@')) {
values.email += '@normadbobu.com';
}
try {
await userStore.authenticate(values);
} catch (error) {
login_in_submission.value = false;
login_alert_variant.value = 'bg-red-500';
login_alert_msg.value = '아이디 또는 비밀번호가 일치하지 않습니다.';
return;
}
login_alert_variant.value = 'bg-green-500';
login_alert_msg.value = '로그인에 성공하였습니다!';
router.push('/');
};
</script>

View File

@@ -4,11 +4,11 @@
<NuxtLink to="/wadiz/manage">TO manage</NuxtLink>
</div>
<div v-else>
<AdminLogin />
<UserLogin />
</div>
</template>
<script setup lang="ts">
import AdminLogin from '~/components/auth/admin-login.vue';
import UserLogin from '~/components/auth/user-login.vue';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);

View File

@@ -0,0 +1,20 @@
<template>
<div v-if="userLoggedIn">
<button @click="userStore.signOut">로그아웃</button>
<NuxtLink to="/wadiz/manage">TO manage</NuxtLink>
</div>
<div v-else>
<RegisterForm />
</div>
</template>
<script setup lang="ts">
import RegisterForm from '~/components/auth/register-form.vue';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
onMounted(() => {
if (userLoggedIn.value) {
navigateTo('/');
}
});
</script>