feat. FX_PREREGIST_01 템플릿 제작

This commit is contained in:
clkim
2025-11-06 21:01:56 +09:00
parent a2112f7b2d
commit e943f3a9a2
9 changed files with 1490 additions and 530 deletions

View File

@@ -0,0 +1,660 @@
<script setup lang="ts">
interface Props {
preregistCode?: string
}
const props = defineProps<Props>()
const { locale, tm } = useI18n()
const modalStore = useModalStore()
const { gameData } = useGameDataStore()
const { handleTokenValidation } = useTokenValidation()
const { getPreregist, setPreregist } = usePreregist()
const { isNorthAmerica, countryCode } = useGds()
// State
const isOpen = ref(false)
const step = ref<1 | 2>(1)
const isSubmitting = ref(false)
const checks = ref({
age: false,
privacy: false,
marketing: false,
})
const birthdate = ref({
month: '',
day: '',
year: '',
})
const expanded = ref({
privacy: false,
marketing: false,
})
const currentYear = new Date().getFullYear()
const currentDate = computed(() => new Date().toISOString().split('T')[0])
const allChecked = computed(() => {
const { age, privacy, marketing } = checks.value
return isNorthAmerica.value
? privacy && marketing
: age && privacy && marketing
})
const isBirthdateValid = computed(() => {
if (!isNorthAmerica.value) return true
const { month, day, year } = birthdate.value
if (!month || !day || !year) return false
const monthNum = Number(month)
const dayNum = Number(day)
const yearNum = Number(year)
return (
monthNum >= 1 &&
monthNum <= 12 &&
dayNum >= 1 &&
dayNum <= 31 &&
yearNum >= 1900 &&
yearNum <= currentYear
)
})
const canSubmit = computed(() => {
const { age, privacy, marketing } = checks.value
if (!privacy || !marketing) return false
return isNorthAmerica.value ? isBirthdateValid.value : age
})
const handleCheckAll = () => {
const newValue = !allChecked.value
checks.value = {
age: isNorthAmerica.value ? false : newValue,
privacy: newValue,
marketing: newValue,
}
}
const handleCheck = (key: keyof typeof checks.value) => {
checks.value[key] = !checks.value[key]
}
const toggleExpand = (key: keyof typeof expanded.value, event: Event) => {
event.stopPropagation()
expanded.value[key] = !expanded.value[key]
}
// 에러 코드 매핑
const ERROR_MESSAGES: Record<number, string> = {
'-90002': '사전 등록 기간이 아닙니다.',
'-90018': '생년월일을 입력해주세요.',
'-90022': '사전 등록 가능한 연령이 아닙니다.',
'-90000': '필수 약관을 모두 선택해 주세요.',
'-90023': '이미 사전 등록을 완료한 계정입니다',
}
const showErrorModal = (code: number) => {
if (!code) return
const message = ERROR_MESSAGES[code]
if (message) {
modalStore.handleOpenAlert({ contentText: message })
return
}
// 기타 오류
modalStore.handleOpenConfirm({
contentText: tm('Alert_Error'),
confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => {
window.open('aa', '_blank')
},
})
}
// 토큰 및 사전등록 여부 검증
const checkValidation = async () => {
if (!props.preregistCode) return false
try {
const accessToken = csrGetAccessToken()
const isValidToken = await handleTokenValidation(accessToken)
if (!isValidToken) return false
// 사전등록 여부 조회
const result = await getPreregist({
accessToken,
event_code: props.preregistCode,
lang: locale.value,
terms_type: 3,
})
if (result.code === -1) {
// 사전등록 가능
return true
}
if (result.code === 0) {
// 이미 사전등록 완료
showErrorModal(-90023)
return false
}
// 기타 오류
showErrorModal(result.code)
return false
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[checkValidation]', error)
}
showErrorModal(-99999)
return false
}
}
// 사전등록 모달 오픈
const handlePreregist = async () => {
if (isSubmitting.value) return
const isValid = await checkValidation()
if (isValid) {
isOpen.value = true
step.value = 1
}
}
// 사전등록 제출
const handleSubmit = async () => {
if (!props.preregistCode) return
if (isSubmitting.value || step.value !== 1) return
// 유효성 검사
if (!canSubmit.value) {
showErrorModal(-90000)
return
}
const isValid = await checkValidation()
if (!isValid) return
isSubmitting.value = true
try {
// 생년월일 포맷팅
const birthDate = isNorthAmerica.value
? `${birthdate.value.year}-${birthdate.value.month.padStart(2, '0')}-${birthdate.value.day.padStart(2, '0')}`
: undefined
// API 호출
const result = await setPreregist({
accessToken: csrGetAccessToken(),
event_code: props.preregistCode,
lang_code: locale.value,
terms_type: 3,
device_type: 'WEB',
country_code: countryCode.value || 'KR',
necessary_consent1: checks.value.age || isNorthAmerica.value ? 'Y' : 'N',
necessary_consent2: checks.value.privacy ? 'Y' : 'N',
necessary_consent3: checks.value.marketing ? 'Y' : 'N',
birth_date: birthDate,
})
if (result.code === 0) {
step.value = 2
return
}
showErrorModal(result.code)
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[handleSubmit]', error)
}
showErrorModal(-99999)
} finally {
isSubmitting.value = false
}
}
// 모달 닫기 및 초기화
const resetForm = () => {
checks.value = {
age: false,
privacy: false,
marketing: false,
}
birthdate.value = {
month: '',
day: '',
year: '',
}
expanded.value = {
privacy: false,
marketing: false,
}
isSubmitting.value = false
}
const handleClose = () => {
if (isSubmitting.value) return
isOpen.value = false
step.value = 1
// 애니메이션 완료 후 초기화
setTimeout(resetForm, 300)
}
defineExpose({
handlePreregist,
})
</script>
<template>
<BlocksModalLayer
:is-open="isOpen"
area-class="h-full bg-[#292929] pt-[60px] md:w-[476px] md:h-[680px] md:pt-[64px] md:rounded-[20px] md:shadow-[0_2px_4px_rgba(0,0,0,0.06)]"
close-class="absolute top-[19px] right-[26px] md:top-[20px] md:right-[24px]"
@close="handleClose"
>
<!-- Step 1: Terms Agreement -->
<div v-if="step === 1" class="flex flex-col h-full">
<div class="flex gap-5 px-5 pt-5 pb-[12px] md:px-10 md:pt-6 md:pb-[16px]">
<h2
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
>
사전 등록 이용 약관에
<br />
동의해주세요
</h2>
<div class="flex h-[30px] items-center gap-1 md:h-[34px]">
<span
class="text-base font-bold leading-6 tracking-[-0.48px] text-[#b2b2b2]"
>
1
</span>
<span
class="text-xs font-bold leading-[18px] tracking-[-0.24px] text-[#666666]"
>
/
</span>
<span
class="text-base font-medium leading-6 tracking-[-0.48px] text-[#666666]"
>
2
</span>
</div>
</div>
<!-- content area -->
<div class="overflow-hidden relative">
<div
class="absolute left-0 right-0 top-0 bg-gradient-to-b from-[#292929] to-transparent z-[1] h-[24px] md:h-[32px]"
></div>
<div
class="overflow-y-auto h-full py-[24px] px-5 md:py-[32px] md:px-10"
>
<div
class="flex h-12 cursor-pointer items-center rounded-lg border border-white/10 bg-[#383838] px-4 md:px-6 gap-2 md:gap-3"
@click="handleCheckAll"
>
<div class="shrink-0">
<AtomsIconsCheckBoldLine
:color="allChecked ? 'var(--primary)' : '#666666'"
/>
</div>
<span
class="flex-1 text-base font-bold leading-6 tracking-[-0.48px] text-[#ebebeb] md:text-lg md:leading-[26px] md:tracking-[-0.54px]"
>
아래 내용에 모두 동의 합니다.
</span>
</div>
<div class="mt-4">
<!-- Age Check (북미 제외) -->
<div v-if="!isNorthAmerica" class="px-3 py-4 md:px-6">
<div
class="flex cursor-pointer items-center gap-3 md:gap-4"
@click="handleCheck('age')"
>
<div class="shrink-0">
<AtomsIconsCheckBoldLine
:color="checks.age ? 'var(--primary)' : '#666666'"
/>
</div>
<span
class="flex-1 text-sm font-medium leading-6 tracking-[-0.42px] text-[#ebebeb] md:text-[15px] md:tracking-[-0.45px]"
>
[필수] 18 이상입니다.
</span>
</div>
</div>
<!-- Privacy Check with Accordion -->
<div class="px-3 py-4 md:px-6">
<div class="flex cursor-pointer items-center gap-3 md:gap-4">
<div class="shrink-0">
<AtomsIconsCheckBoldLine
:color="checks.privacy ? 'var(--primary)' : '#666666'"
/>
</div>
<span
class="flex-1 text-sm font-medium leading-6 tracking-[-0.42px] text-[#ebebeb] md:text-[15px] md:tracking-[-0.45px]"
@click="handleCheck('privacy')"
>
[필수] 개인정보 수집 이용동의
</span>
<button
type="button"
class="flex items-center justify-center transition-transform duration-200"
:class="{ 'rotate-180': expanded.privacy }"
@click="toggleExpand('privacy', $event)"
>
<AtomsIconsArrowDownLine />
</button>
</div>
<!-- Privacy Detail Content -->
<div
v-if="expanded.privacy"
class="mt-4 h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
>
<p
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2]"
>
약관은 [게임명] (이하 "회사") 제공하는 사전등록 서비스와
관련하여 회사와 이용자 간의 권리, 의무 책임사항, 서비스
이용조건 절차 기본적인 사항을 규정함을 목적으로 합니다.
<br />
<br />
회사는 이용자의 개인정보를 중요시하며, 개인정보 보호법,
정보통신망 이용촉진 정보보호 등에 관한 법률 준수하고
있습니다.
<br />
<br />
수집하는 개인정보 항목: 이메일 주소, 휴대전화번호, 게임 계정
정보
<br />
개인정보 수집 목적: 사전등록 확인, 게임 출시 안내, 마케팅 활용
<br />
개인정보 보유 이용기간: 회원 탈퇴 시까지
</p>
</div>
</div>
<!-- Marketing Check with Accordion -->
<div class="px-3 py-4 md:px-6">
<div class="flex cursor-pointer items-center gap-3 md:gap-4">
<div class="shrink-0">
<AtomsIconsCheckBoldLine
:color="checks.marketing ? 'var(--primary)' : '#666666'"
/>
</div>
<span
class="flex-1 text-sm font-medium leading-6 tracking-[-0.42px] text-[#ebebeb] md:text-[15px] md:tracking-[-0.45px]"
@click="handleCheck('marketing')"
>
[필수] 게임 서비스의 유용한 소식 받기
</span>
<button
type="button"
class="flex items-center justify-center transition-transform duration-200"
:class="{ 'rotate-180': expanded.marketing }"
@click="toggleExpand('marketing', $event)"
>
<AtomsIconsArrowDownLine />
</button>
</div>
<!-- Marketing Detail Content -->
<div
v-if="expanded.marketing"
class="mt-4 h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
>
<p
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2]"
>
회사는 다음의 목적으로 광고성 정보를 전송합니다:
<br />
<br />
게임 출시 안내 업데이트 정보
<br />
이벤트, 프로모션 등의 마케팅 정보
<br />
게임 혜택 쿠폰 제공
<br />
신규 콘텐츠 패치 정보
<br />
<br />
전송방법: 이메일, SMS, 푸시 알림, 알림
<br />
<br />
이용자는 언제든지 수신 동의를 철회할 있으며, 수신 거부
시에도 서비스 이용에는 제한이 없습니다. , 거래 관련 정보,
고객문의 답변 의무적으로 안내되어야 하는 정보는 수신동의
여부와 무관하게 제공됩니다.
</p>
</div>
</div>
<!-- Birthdate Input (북미 ) -->
<div
v-if="isNorthAmerica"
class="flex flex-col gap-6 pb-4 pl-[44px] pr-3 pt-2 md:pl-[60px] md:pr-6"
>
<!-- Divider -->
<div class="h-px w-full bg-white/[0.06]"></div>
<!-- Birthdate Form -->
<div class="flex flex-col gap-3 md:gap-4">
<p
class="text-sm font-medium leading-6 tracking-[-0.42px] text-[#ebebeb] md:text-[15px] md:tracking-[-0.45px]"
>
[필수] 생년월일을 입력해주세요
</p>
<div class="flex gap-2">
<!-- Month -->
<label class="flex-1">
<span class="sr-only">Month</span>
<input
id="birthdate-month"
v-model="birthdate.month"
type="number"
name="birthdate-month"
placeholder="Month"
min="1"
max="12"
aria-label="Birth month"
class="h-12 w-full rounded-lg border border-[#595959] bg-[#292929] px-4 py-3 text-sm font-normal leading-5 tracking-[-0.42px] text-[#ebebeb] placeholder:text-[#666666] focus:border-[#7f7f7f] focus:outline-none"
/>
</label>
<!-- Day -->
<label class="flex-1">
<span class="sr-only">Day</span>
<input
id="birthdate-day"
v-model="birthdate.day"
type="number"
name="birthdate-day"
placeholder="Day"
min="1"
max="31"
aria-label="Birth day"
class="h-12 w-full rounded-lg border border-[#595959] bg-[#292929] px-4 py-3 text-sm font-normal leading-5 tracking-[-0.42px] text-[#ebebeb] placeholder:text-[#666666] focus:border-[#7f7f7f] focus:outline-none"
/>
</label>
<!-- Year -->
<label class="flex-1">
<span class="sr-only">Year</span>
<input
id="birthdate-year"
v-model="birthdate.year"
type="number"
name="birthdate-year"
placeholder="Year"
min="1900"
:max="currentYear"
aria-label="Birth year"
class="h-12 w-full rounded-lg border border-[#595959] bg-[#292929] px-4 py-3 text-sm font-normal leading-5 tracking-[-0.42px] text-[#ebebeb] placeholder:text-[#666666] focus:border-[#7f7f7f] focus:outline-none"
/>
</label>
</div>
</div>
</div>
</div>
</div>
<div
class="absolute left-0 right-0 bottom-0 bg-gradient-to-t from-[#292929] to-transparent z-[1] h-[24px] md:h-[32px]"
></div>
</div>
<div class="mt-auto px-5 pb-10 md:px-10 md:pb-12">
<AtomsButton
class="w-full"
button-size="size-small md:size-medium"
:disabled="!canSubmit || isSubmitting"
@click="handleSubmit"
>
사전 등록하기
</AtomsButton>
</div>
</div>
<!-- Step 2: Success -->
<div v-if="step === 2" class="flex flex-1 flex-col h-full">
<div class="flex gap-5 px-5 pb-10 pt-5 md:px-10 md:pb-12 md:pt-6">
<p
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
>
사전 등록이 완료되었습니다.
</p>
<div class="flex h-[30px] items-center gap-1 md:h-[34px]">
<span
class="text-base font-bold leading-6 tracking-[-0.48px] text-[#b2b2b2]"
>
2
</span>
<span
class="text-xs font-bold leading-[18px] tracking-[-0.24px] text-[#666666]"
>
/
</span>
<span
class="text-base font-medium leading-6 tracking-[-0.48px] text-[#666666]"
>
2
</span>
</div>
</div>
<div class="flex flex-col gap-10 px-5 pb-10 md:px-10">
<!-- Success Info -->
<div
class="flex flex-col gap-1 rounded-lg border border-white/10 bg-[#383838] px-5 py-4 md:gap-2 md:px-6"
>
<p
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2] opacity-50 md:text-[15px] md:leading-6 md:tracking-[-0.45px]"
>
{{ currentDate }}
</p>
<h3
class="text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
>
{{ gameData?.game_name }}
</h3>
<p
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#ebebeb] md:text-[15px] md:leading-6 md:tracking-[-0.45px]"
>
광고성 정보 수신에 동의하였습니다.
</p>
</div>
<!-- STOVE App Download -->
<div class="flex flex-col gap-5">
<p
class="text-left text-sm font-medium leading-6 tracking-[-0.42px] text-[#ebebeb] md:text-center md:text-base md:leading-[26px] md:tracking-[-0.48px]"
>
STOVE APP 다운로드 하고 정식 런칭 소식 알림 받기!
</p>
<div class="flex items-center gap-3">
<div
class="flex size-[108px] p-4 shrink-0 items-center justify-center rounded-lg bg-white/[0.04] backdrop-blur-[15px] md:size-[124px] md:p-4.5"
>
<AtomsImg
src="/images/common/stove_app_qr.png"
alt="STOVE APP QR Code"
image-type="common"
class="w-full h-full object-contain"
/>
</div>
<div class="flex flex-1 flex-col gap-3">
<a
href="https://play.google.com/store/search?q=stove&c=apps"
target="_blank"
class="flex h-12 w-full items-center justify-center gap-1.5 rounded-lg bg-white/[0.04] px-8 pl-8 pr-10 text-sm font-medium leading-5 tracking-[-0.42px] text-white no-underline backdrop-blur-[15px] transition-colors duration-200 hover:bg-white/[0.08] md:h-14 md:gap-2 md:text-base md:leading-6 md:tracking-[-0.48px]"
>
<AtomsIconsLogoGoogle />
<span>Google Play</span>
</a>
<a
href="https://apps.apple.com/app/stove-app-stove-app/id1342134971"
target="_blank"
class="flex h-12 w-full items-center justify-center gap-1.5 rounded-lg bg-white/[0.04] px-8 pl-8 pr-10 text-sm font-medium leading-5 tracking-[-0.42px] text-white no-underline backdrop-blur-[15px] transition-colors duration-200 hover:bg-white/[0.08] md:h-14 md:gap-2 md:text-base md:leading-6 md:tracking-[-0.48px]"
>
<AtomsIconsLogoApple />
<span>App Store</span>
</a>
</div>
</div>
</div>
</div>
</div>
</BlocksModalLayer>
</template>
<style scoped>
.modal-wrap {
@apply p-0 md:p-5;
}
.modal-wrap:deep(.modal-content) {
@apply h-full;
}
.modal-wrap:deep(.modal-close) svg {
@apply fill-white;
}
/* Custom scrollbar for accordion content */
:deep(.overflow-y-auto) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
:deep(.overflow-y-auto::-webkit-scrollbar) {
width: 4px;
}
:deep(.overflow-y-auto::-webkit-scrollbar-track) {
background: transparent;
}
:deep(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
}
:deep(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.25);
}
/* Remove number input spinner */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>