fix. 사전등록 로직 수정

This commit is contained in:
clkim
2025-11-07 16:29:44 +09:00
parent b5f723397b
commit bcbc7e9c6d
5 changed files with 500 additions and 582 deletions

View File

@@ -16,4 +16,16 @@
border: none;
outline: none;
}
/* 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;
}
}

View File

@@ -1,35 +1,58 @@
<script setup lang="ts">
import { PREREGIST_ERROR_CODE } from '#layers/composables/usePreregist'
interface Props {
preregistCode?: string
tm?: any // i18n translate message function
tm?: (key: string) => string
}
const props = defineProps<Props>()
// Composables
const { locale } = useI18n()
const device = useDevice()
const runtimeConfig = useRuntimeConfig()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const { handleTokenValidation } = useTokenValidation()
const { getPreregist, setPreregist } = usePreregist()
const { isNorthAmerica, countryCode } = useGds()
const {
countryCode,
preregistDate,
checkCountryByIp,
getPreregist,
setPreregist,
} = usePreregist()
const { gameData } = storeToRefs(gameDataStore)
// Constants
const stoveCs = runtimeConfig.public.stoveCs
const customerServiceUrl = `${stoveCs}/${gameData.value?.game_id}`
// Props로 전달받은 tm 또는 전역 i18n의 tm 사용
const t = (key: string) => {
if (props.tm) {
return props.tm(key)
}
return key
/**
* 번역 함수 (Props로 전달받은 tm 또는 key 반환)
*/
const tm = (key: string): string => {
return props.tm?.(key) ?? key
}
// 게임명 치환 헬퍼
const tWithGameName = (key: string) => {
const text = t(key)
const isModalOpen = ref(false)
const currentStep = ref<1 | 2>(1)
const isSubmitting = ref(false)
const isCheckedMarketing = ref(false)
const isExpandedMarketing = ref(false)
const isValidated = ref(false) // 검증 완료 여부 (중복 검증 방지)
const canSubmit = computed(() => isCheckedMarketing.value)
const errorMessages = computed<Record<number, string>>(() => ({
[PREREGIST_ERROR_CODE.NOT_PERIOD]: tm('Preregist_Alert_Date'),
[PREREGIST_ERROR_CODE.REQUIRED_TERMS]: tm('Preregist_Alert_Agree'),
[PREREGIST_ERROR_CODE.AGE_RESTRICTION]: tm('Preregist_Alert_Age'),
[PREREGIST_ERROR_CODE.ALREADY_REGISTERED]: tm('Preregist_Alert_Already'),
}))
const tmWithGameName = (key: string): string => {
const text = tm(key)
if (typeof text === 'string' && text.includes('%게임명%')) {
const gameName = gameData.value?.game_name ?? ''
return text.replace(/%게임명%/g, gameName)
@@ -37,111 +60,64 @@ const tWithGameName = (key: string) => {
return text
}
// 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 toggleMarketing = () => {
isCheckedMarketing.value = !isCheckedMarketing.value
}
const handleCheck = (key: keyof typeof checks.value) => {
checks.value[key] = !checks.value[key]
}
const toggleExpand = (key: keyof typeof expanded.value, event: Event) => {
const toggleExpand = (event: Event) => {
event.stopPropagation()
expanded.value[key] = !expanded.value[key]
isExpandedMarketing.value = !isExpandedMarketing.value
}
// 에러 코드 매핑
const ERROR_MESSAGES: Record<number, string> = {
'-90002': t('Preregist_Alert_Date'),
'-90018': t('Preregist_Alert_Birth'),
'-90022': t('Preregist_Alert_Age'),
'-90000': t('Preregist_Alert_Agree'),
'-90023': t('Preregist_Alert_Already'),
}
const showErrorModal = (code: number) => {
/**
* 에러 모달 표시
*/
const showErrorModal = (code: number): void => {
if (!code) return
const message = ERROR_MESSAGES[code]
// 일반 에러 메시지
const message = errorMessages.value[code]
if (message) {
modalStore.handleOpenAlert({ contentText: message })
return
}
// 로그인 필요
if (code === PREREGIST_ERROR_CODE.LOGIN_REQUIRED) {
modalStore.handleOpenConfirm({
contentText: tm('Alert_StoveLogin'),
confirmButtonText: tm('Text_StoveLogin'),
confirmButtonEvent: () => {
csrGoStoveLogin()
},
})
return
}
// 기타 오류
modalStore.handleOpenConfirm({
contentText: t('Alert_Error'),
confirmButtonText: t('Text_Customer'),
contentText: tm('Alert_Error'),
confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => {
window.open(customerServiceUrl, '_blank')
},
})
}
// 토큰 및 사전등록 여부 검증
const checkValidation = async () => {
if (!props.preregistCode) return false
/**
* 토큰 및 사전등록 여부 검증
*/
const checkValidation = async (): Promise<boolean> => {
if (!props.preregistCode) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[Preregist] preregistCode is required')
}
return false
}
try {
// 토큰 검증
const accessToken = csrGetAccessToken()
const isValidToken = await handleTokenValidation(accessToken)
if (!isValidToken) return false
@@ -154,14 +130,14 @@ const checkValidation = async () => {
terms_type: 3,
})
if (result.code === -1) {
// 사전등록 가능
// 사전등록 가능
if (result.code === PREREGIST_ERROR_CODE.NO_DATA) {
return true
}
if (result.code === 0) {
// 이미 사전등록 완료
showErrorModal(-90023)
// 이미 사전등록 완료
if (result.code === PREREGIST_ERROR_CODE.SUCCESS) {
showErrorModal(PREREGIST_ERROR_CODE.ALREADY_REGISTERED)
return false
}
@@ -171,61 +147,75 @@ const checkValidation = async () => {
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[checkValidation]', error)
console.error('[Preregist.checkValidation]', error)
}
showErrorModal(-99999)
showErrorModal(PREREGIST_ERROR_CODE.UNKNOWN)
return false
}
}
// 사전등록 모달 오픈
const handlePreregist = async () => {
/**
* 사전등록 모달 오픈
*/
const handleOpenPreregist = async (): Promise<void> => {
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
// 국가 정보 조회
if (!countryCode.value) {
await checkCountryByIp({
policy_grp: 'onstove',
device_nation: csrGetCountry().toUpperCase(),
client_lang: locale.value,
include_coverages: false,
qc: csrGetQc(),
runType: runtimeConfig.public.runType,
})
}
isValidated.value = true // 검증 완료 플래그
isModalOpen.value = true
currentStep.value = 1
}
/**
* 사전등록 제출
*/
const handleSubmit = async (): Promise<void> => {
if (!props.preregistCode) return
if (isSubmitting.value || currentStep.value !== 1) return
// 유효성 검사
if (!canSubmit.value) {
showErrorModal(PREREGIST_ERROR_CODE.REQUIRED_TERMS)
return
}
// 이미 검증을 통과한 경우 재검증 스킵
if (!isValidated.value) {
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',
device_type: device.isMobile ? 'mobile' : 'pc',
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,
necessary_consent1: 'Y',
necessary_consent2: 'Y',
necessary_consent3: isCheckedMarketing.value ? 'Y' : 'N',
birth_date: '',
})
if (result.code === 0) {
step.value = 2
if (result.code === PREREGIST_ERROR_CODE.SUCCESS) {
currentStep.value = 2
return
}
@@ -233,60 +223,46 @@ const handleSubmit = async () => {
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[handleSubmit]', error)
console.error('[Preregist.handleSubmit]', error)
}
showErrorModal(-99999)
showErrorModal(PREREGIST_ERROR_CODE.UNKNOWN)
} 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 = () => {
/**
* 모달 닫기 및 상태 초기화
*/
const handleCloseModal = (): void => {
if (isSubmitting.value) return
step.value = 1
// 애니메이션 완료 후 초기화
setTimeout(resetForm, 300)
currentStep.value = 1
isCheckedMarketing.value = false
isExpandedMarketing.value = false
isSubmitting.value = false
isValidated.value = false // 검증 플래그도 초기화
}
defineExpose({
handlePreregist,
handleOpenPreregist,
})
</script>
<template>
<BlocksModalLayer
v-model:is-open="isOpen"
v-model:is-open="isModalOpen"
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"
@close="handleCloseModal"
>
<!-- Step 1: Terms Agreement -->
<div v-if="step === 1" class="flex flex-col h-full">
<div v-if="currentStep === 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]">
<h4
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
>
{{ t('Preregist_Modal_Title01') }}
{{ tm('Preregist_Modal_Title01') }}
</h4>
<div
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
@@ -305,177 +281,38 @@ defineExpose({
<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]"
>
{{ t('Preregist_Agree_All') }}
</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="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="isCheckedMarketing ? '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="toggleMarketing"
>
<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]"
>
{{ t('Preregist_Agree_Age') }}
</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')"
>
{{ t('Preregist_Agree_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 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
{{ tmWithGameName('Preregist_Agree_News') }}
</span>
<button
type="button"
class="flex items-center justify-center transition-transform duration-200"
:class="{ 'rotate-180': isExpandedMarketing }"
@click="toggleExpand($event)"
>
<p
v-dompurify-html="t('Preregist_Agree_Privacy_Info')"
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2]"
></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')"
>
{{ t('Preregist_Agree_News') }}
</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 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
>
<p
v-dompurify-html="tWithGameName('Preregist_Agree_News_Info')"
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2]"
></p>
</div>
<AtomsIconsArrowDownLine />
</button>
</div>
<!-- Birthdate Input (북미 ) -->
<!-- Marketing Detail Content -->
<div
v-if="isNorthAmerica"
class="flex flex-col gap-6 pb-4 pl-[44px] pr-3 pt-2 md:pl-[60px] md:pr-6"
v-if="isExpandedMarketing"
class="mt-4 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
>
<!-- 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]"
>
{{ t('Preregist_Agree_Birth') }}
</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>
<p
v-dompurify-html="tmWithGameName('Preregist_Agree_News_Info')"
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#b2b2b2]"
></p>
</div>
</div>
</div>
@@ -491,18 +328,18 @@ defineExpose({
:disabled="!canSubmit || isSubmitting"
@click="handleSubmit"
>
{{ t('Preregist_Btn_Preegist') }}
{{ tm('Preregist_Btn_Preegist') }}
</AtomsButton>
</div>
</div>
<!-- Step 2: Success -->
<div v-if="step === 2" class="flex flex-1 flex-col h-full">
<div v-if="currentStep === 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">
<h4
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
>
{{ t('Preregist_Modal_Title02') }}
{{ tm('Preregist_Modal_Title02') }}
</h4>
<div
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
@@ -521,7 +358,7 @@ defineExpose({
<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 }}
{{ preregistDate }}
</p>
<h3
class="text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
@@ -531,7 +368,7 @@ defineExpose({
<p
class="text-[13px] font-normal leading-[22px] tracking-[-0.325px] text-[#ebebeb] md:text-[15px] md:leading-6 md:tracking-[-0.45px]"
>
{{ t('Preregist_Agree_News_Complete') }}
{{ tm('Preregist_Agree_News_Complete') }}
</p>
</div>
@@ -540,7 +377,7 @@ defineExpose({
<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]"
>
{{ t('Preregist_Stove_Download') }}
{{ tm('Preregist_Stove_Download') }}
</p>
<div class="flex items-center gap-3">
<div
@@ -611,16 +448,4 @@ defineExpose({
: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>

View File

@@ -9,109 +9,147 @@ import { DEFAULT_LOCALE_CODE } from '@/i18n.config'
*
* @description https://wiki.smilegate.net/display/SDKAPIDOCU/51-09.+gds
*/
const COUNTRY_GROUPS = {
KOREA: ['KR'] as const,
NORTH_AMERICA: ['US', 'CA'] as const,
TAIWAN_HONG_KONG_MACAU: ['TW', 'HK', 'MO'] as const,
} as const
const ERROR_CODE = {
UNKNOWN: -99999,
} as const
const useGds = () => {
const logPrefix = {
exception: '[Exception] /composables/useGds',
failure: '[Failure] /composables/useGds',
}
const countryCode = ref('')
const isKorea = ref(false)
const isTaiwanHongKongMacau = ref(false)
const isNorthAmerica = ref(false)
const countryCode = ref('') // 국가 코드
const isKorea = ref(false) // 대한민국 여부
const isTaiwanHongKongMacau = ref(false) // 대홍마 여부
const isNorthAmerica = ref(false) // 북미 여부
// [Setter] 사전등록 - 국가 코드 세팅
const setCountryCode = (newCountryCode: string) => {
countryCode.value = newCountryCode
}
// [Setter] 사전등록 - 대한민국 여부 세팅
const setIsKorea = (newIsKorea: boolean) => {
isKorea.value = newIsKorea
}
// [Setter] 사전등록 - 대홍마 여부 세팅
const setIsTaiwanHongKongMacau = (newIsTaiwanHongKongMacau: boolean) => {
isTaiwanHongKongMacau.value = newIsTaiwanHongKongMacau
}
// [Setter] 사전등록 - 북미 여부 세팅
const setIsNorthAmerica = (newIsNorthAmerica: boolean) => {
isNorthAmerica.value = newIsNorthAmerica
}
// 사전 등록 - 특정 국가 여부 조회 (IP 기반)
const checkCountryByIp = async (req: ReqGetGdsClientPolicyTotal) => {
let res: ResGetGdsClientPolicyTotal
const arrKorea = ['KR'] // cf. UM: 미국령 군소 제도의 기 미국령 군소 제도, VI: 미국령 버진아일랜드의 기 미국령 버진아일랜드
const arrNorthAmerica = ['US', 'CA'] // cf. UM: 미국령 군소 제도의 기 미국령 군소 제도, VI: 미국령 버진아일랜드의 기 미국령 버진아일랜드
const arrTaiwanHongKongMacau = ['TW', 'HK', 'MO'] // 대만, 홍콩, 마카오 국가 코드
try {
if (`${req.runType}` !== 'live' && req.qc != null && req.qc !== '') {
// Live가 아닌 환경에서 qc가 있으면 국가 코드로 설정하여 판별
setCountryCode(req.qc || '')
setIsKorea(arrKorea.includes(req.qc))
setIsTaiwanHongKongMacau(arrTaiwanHongKongMacau.includes(req.qc))
setIsNorthAmerica(arrNorthAmerica.includes(req.qc))
res = {
code: 0,
message: '',
res_code: 0,
res_data: {
is_default: true,
nation: req.qc,
regulation: '',
timezone: '',
utc_offset: 0,
lang: req.client_lang || DEFAULT_LOCALE_CODE,
coverages: [],
},
} as ResGetGdsClientPolicyTotal
} else {
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const url = `${stoveApiBaseUrl}/gds/v2/client/policy/total`
const query = {
policy_grp: req.policy_grp || 'onstove',
device_nation: req.device_nation || 'KR',
client_lang: req.client_lang || DEFAULT_LOCALE_CODE,
include_coverages: req.include_coverages || false,
}
res = (await commonFetch('GET', url, {
query,
})) as ResGetGdsClientPolicyTotal
if (res != null && res.res_code === 0) {
// is_default = false : 국가코드를 정상 처리했으나 타임존을 처리하지 못한 경우
// is_default = true : device_nation 또는 policy_grp 별 디폴트 값으로 처리된 경우
if (res.res_data != null && res.res_data.nation != null) {
setCountryCode(res.res_data.nation || '')
setIsKorea(arrKorea.includes(res.res_data.nation))
setIsTaiwanHongKongMacau(
arrTaiwanHongKongMacau.includes(res.res_data.nation)
)
setIsNorthAmerica(arrNorthAmerica.includes(res.res_data.nation))
}
}
}
} catch (e) {
console.error(`${logPrefix.exception}.checkCountryByIp: `, e)
res = {
code: -99999,
message: `${e}`,
}
setCountryCode('')
setIsKorea(false)
setIsTaiwanHongKongMacau(false)
setIsNorthAmerica(false)
/**
* 국가별 플래그 업데이트
*/
const updateCountryFlags = (nation: string) => {
if (!nation) {
countryCode.value = ''
isKorea.value = false
isTaiwanHongKongMacau.value = false
isNorthAmerica.value = false
return
}
const upperNation = nation.toUpperCase()
countryCode.value = upperNation
isKorea.value = COUNTRY_GROUPS.KOREA.includes(upperNation as any)
isTaiwanHongKongMacau.value =
COUNTRY_GROUPS.TAIWAN_HONG_KONG_MACAU.includes(upperNation as any)
isNorthAmerica.value = COUNTRY_GROUPS.NORTH_AMERICA.includes(
upperNation as any
)
}
/**
* 에러 응답 생성
*/
const createErrorResponse = (message: string): ResGetGdsClientPolicyTotal => {
return {
code: ERROR_CODE.UNKNOWN,
message,
res_code: ERROR_CODE.UNKNOWN,
res_data: undefined as any,
}
}
/**
* Mock 응답 생성 (개발/QA 환경)
*/
const createMockResponse = (
qc: string,
clientLang: string
): ResGetGdsClientPolicyTotal => {
return {
code: 0,
message: '',
res_code: 0,
res_data: {
is_default: true,
nation: qc.toUpperCase(),
regulation: '',
timezone: '',
utc_offset: 0,
lang: clientLang || DEFAULT_LOCALE_CODE,
coverages: [],
},
}
}
/**
* 사전 등록 - 특정 국가 여부 조회 (IP 기반)
*/
const checkCountryByIp = async (
req: ReqGetGdsClientPolicyTotal
): Promise<ResGetGdsClientPolicyTotal> => {
try {
// runType 우선순위: req.runType > runtimeConfig.runType
const runtimeConfig = useRuntimeConfig()
const runType = req.runType || runtimeConfig.public.runType
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
// Mock 모드 (개발/QA 환경)
const isMockMode = runType !== 'live' && req.qc && req.qc !== ''
if (isMockMode) {
const mockQc = req.qc!.toUpperCase()
updateCountryFlags(mockQc)
return createMockResponse(mockQc, req.client_lang || '')
}
// 실제 API 호출
const url = `${stoveApiBaseUrl}/gds/v2/client/policy/total`
const query = {
policy_grp: req.policy_grp || 'onstove',
device_nation: req.device_nation || 'KR',
client_lang: req.client_lang || DEFAULT_LOCALE_CODE,
include_coverages: req.include_coverages ?? false,
}
const res = (await commonFetch('GET', url, {
query,
})) as ResGetGdsClientPolicyTotal
// 성공 응답 처리
if (res.res_code === 0 && res.res_data?.nation) {
updateCountryFlags(res.res_data.nation)
return res
}
// 실패 응답 처리
// eslint-disable-next-line no-console
console.error('[useGds].checkCountryByIp: Invalid response', res)
updateCountryFlags('')
return res
} catch (error) {
// 에러 로깅
// eslint-disable-next-line no-console
console.error('[useGds].checkCountryByIp: Exception', error)
// 상태 초기화
updateCountryFlags('')
// 에러 응답 반환
return createErrorResponse(
error instanceof Error ? error.message : String(error)
)
}
return res
}
return {
isKorea,
isTaiwanHongKongMacau,
isNorthAmerica,
countryCode,
// Reactive state
isKorea: readonly(isKorea),
isTaiwanHongKongMacau: readonly(isTaiwanHongKongMacau),
isNorthAmerica: readonly(isNorthAmerica),
countryCode: readonly(countryCode),
// Methods
checkCountryByIp,
}
}

View File

@@ -11,20 +11,24 @@ import { countryDialingCodes } from '#layers/assets/data/countryData'
/**
* 프로모션 - 사전등록
*/
const PREREGIST_ERROR_CODE = {
SUCCESS: 0,
NO_DATA: -1, // 조회된 데이터가 없습니다 (최초)
NOT_PERIOD: -90002, // 사전 등록 기간이 아닙니다
REQUIRED_TERMS: -90000, // 필수 약관을 모두 선택해 주세요
// BIRTH_DATE_REQUIRED: -90018, // 생년 월일을 입력해 주세요
AGE_RESTRICTION: -90022, // 사전 등록 가능한 연령이 아닙니다
ALREADY_REGISTERED: -90023, // 이미 사전 등록이 완료된 계정입니다
LOGIN_REQUIRED: -90028, // 로그인 후 이용하실 수 있습니다
// MAINTENANCE: -90003, // 점검 진행 중
UNKNOWN: -99999, // 알 수 없는 오류
} as const
const usePreregist = () => {
const preregistDate = ref(Date.now()) // 사전 등록일
const preregistDate = ref(Date.now())
// [Setter] 사전등록 - 사전 등록일 세팅
const setPreregistDate = (newPreregistDate: number) => {
preregistDate.value = newPreregistDate
}
// 국가 번호 조회
const countryDialingCode = computed(() => {
return countryDialingCodes[countryCode.value.toUpperCase()]
})
// 사전 등록 - 특정 국가 여부 조회 (IP 기반)
// GDS composable
const {
isKorea,
isTaiwanHongKongMacau,
@@ -33,14 +37,54 @@ const usePreregist = () => {
checkCountryByIp,
} = useGds()
// 사전 등록 - (등록 여부) 조회
const getPreregist = async (req: ReqPreorderSelectEvent) => {
let res: ResPreorderSelectEvent = {} as ResPreorderSelectEvent
// 국가 번호 조회
const countryDialingCode = computed(() => {
const code = countryCode.value?.toUpperCase()
return code ? countryDialingCodes[code] : undefined
})
/**
* 사전 등록일 세팅 (숫자 검증)
*/
const setPreregistDate = (dateValue: number | string | undefined) => {
if (dateValue && isNumeric(String(dateValue))) {
preregistDate.value = Number(dateValue)
} else {
preregistDate.value = Date.now()
}
}
/**
* 에러 응답 생성
*/
const createErrorResponse = <T extends { code: number; message: string }>(
code: number,
message: string = ''
): T => {
return { code, message } as T
}
/**
* 401 에러를 LOGIN_REQUIRED로 정규화
*/
const normalizeAuthError = (code: number): number => {
return String(code).startsWith('401')
? PREREGIST_ERROR_CODE.LOGIN_REQUIRED
: code
}
/**
* 사전 등록 - 조회 (등록 여부)
*/
const getPreregist = async (
req: ReqPreorderSelectEvent
): Promise<ResPreorderSelectEvent> => {
try {
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const url = `${stoveApiBaseUrl}/pub-comm/v1.0/Preorder/SelectEvent`
const headers = {
Authorization: `Bearer ${req.accessToken}`,
}
@@ -49,59 +93,69 @@ const usePreregist = () => {
lang: req.lang || DEFAULT_LOCALE_CODE,
terms_type: req.terms_type,
}
res = (await commonFetch('POST', url, {
const res = (await commonFetch('POST', url, {
headers,
body,
})) as ResPreorderSelectEvent
if (res != null) {
if (res.code === 0) {
setPreregistDate(res.value?.terms_time_long || Date.now())
} else if (res.code === -1) {
//= 조회 된 데이터가 없습니다. -> 최초
} else if (res.code === -90002) {
//= 사전 등록 기간이 아닙니다.
} else if (`${res.code}`.startsWith('401')) {
//= [01] 로그인 후 이용하실 수 있습니다.
res = { code: -90028, message: res.message || '' } // 401은 -90028로 처리
} else if (res.code === -90028) {
//= [01] 로그인 후 이용하실 수 있습니다.
} else {
// [-90003] 점검 진행 중
res = { code: res.code, message: res.message || '' }
console.log(
'[Failure] /composables/promotion/usePreregist.getPreregist: ',
res
)
res.code = -99999 // else 알럿 띄우기 용 세팅
}
} else {
res = { code: -99999, message: '' }
console.log(
'[Failure] /composables/promotion/usePreregist.getPreregist: ',
res
)
res.code = -99999 // else 알럿 띄우기 용 세팅
// 응답 검증
if (!res) {
// eslint-disable-next-line no-console
console.error('[usePreregist].getPreregist: Empty response')
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN)
}
} catch (e) {
console.error(
'[Exception] /composables/promotion/usePreregist.getPreregist: ',
e
// 정규화된 에러 코드
const normalizedCode = normalizeAuthError(res.code)
// 성공 케이스
if (normalizedCode === PREREGIST_ERROR_CODE.SUCCESS) {
setPreregistDate(res.value?.terms_time_long ?? Date.now())
return res
}
// 예상된 에러 케이스 (NO_DATA, NOT_PERIOD, LOGIN_REQUIRED)
const expectedErrors: number[] = [
PREREGIST_ERROR_CODE.NO_DATA,
PREREGIST_ERROR_CODE.NOT_PERIOD,
PREREGIST_ERROR_CODE.LOGIN_REQUIRED,
]
if (expectedErrors.includes(normalizedCode)) {
return createErrorResponse(normalizedCode, res.message)
}
// 예상치 못한 에러
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[usePreregist].getPreregist: Unexpected error', res)
}
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN, res.message)
} catch (error) {
// eslint-disable-next-line no-console
console.error('[usePreregist].getPreregist: Exception', error)
return createErrorResponse(
PREREGIST_ERROR_CODE.UNKNOWN,
error instanceof Error ? error.message : String(error)
)
res = {
code: -99999,
message: `${e}`,
}
}
return res
}
// 사전 등록 - 저장
const setPreregist = async (req: ReqPreorderReserveDataUpdate) => {
let res: ResPreorderReserveDataUpdate = {} as ResPreorderReserveDataUpdate
/**
* 사전 등록 - 저장
*/
const setPreregist = async (
req: ReqPreorderReserveDataUpdate
): Promise<ResPreorderReserveDataUpdate> => {
try {
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
if (!stoveApiBaseUrl) {
throw new Error('stoveApiUrl is not configured')
}
const url = `${stoveApiBaseUrl}/pub-comm/v1.0/Preorder/ReserveDataUpdate`
const headers = {
Authorization: `Bearer ${req.accessToken}`,
@@ -116,104 +170,93 @@ const usePreregist = () => {
lang_code: req.lang_code,
hp: req.hp,
email: req.email,
metric_seq: req.metric_seq, //= mcode
metric_seq: req.metric_seq,
g_server: req.g_server,
world_id: req.world_id,
game_unique_num: req.game_unique_num,
event_info1: req.event_info1, // 각 이벤트 필요할때 사용
event_info2: req.event_info2, // 각 이벤트 필요할때 사용
event_info3: req.event_info3, // 각 이벤트 필요할때 사용
event_info4: req.event_info4, // 각 이벤트 필요할때 사용
under14_terms: req.under14_terms, // 14세미만 진행 하는 구분값
device_type: req.device_type, // 접속 기기 pc / mobile
event_info1: req.event_info1,
event_info2: req.event_info2,
event_info3: req.event_info3,
event_info4: req.event_info4,
under14_terms: req.under14_terms,
device_type: req.device_type,
country_code: req.country_code,
country_dialing_code: req.country_dialing_code, // 국가 번호
birth_date: req.birth_date, // 생년월일 - 북미(미국, 캐나다)의 경우
country_dialing_code: req.country_dialing_code,
birth_date: req.birth_date,
}
res = (await commonFetch('POST', url, {
const res = (await commonFetch('POST', url, {
headers,
body,
})) as ResPreorderReserveDataUpdate
if (res != null) {
if (res.code === 0) {
//= [08] [사전 등록 완료 레이어] 노출
if (
res.message != null &&
res.message !== '' &&
isNumeric(res.message)
) {
// 사전 등록일이 숫자면 날짜로 변환
setPreregistDate(Number(res.message))
} else {
setPreregistDate(Date.now())
}
} else if (`${res.code}`.startsWith('401')) {
//= [01] 로그인 후 이용하실 수 있습니다.
res = { code: -90028, message: res.message || '' } // 401은 -90028로 처리
} else if (res.code === -90028) {
//= [01] 로그인 후 이용하실 수 있습니다.
} else if (res.code === -90002) {
//= [02] 사전 등록 기간이 아닙니다.
} else if (res.code === -90018) {
//= [04] 생년 월일을 입력해 주세요.
} else if (res.code === -90022) {
//= [05] 사전 등록 가능한 연령이 아닙니다.
} else if (res.code === -90000) {
//= [06] 필수 약관을 모두 선택해 주세요.
} else if (res.code === -90023) {
//= [07] 이미 사전 등록이 완료된 계정입니다.
if (
res.message != null &&
res.message !== '' &&
isNumeric(res.message)
) {
// 사전 등록일이 숫자면 날짜로 변환
setPreregistDate(Number(res.message))
} else {
setPreregistDate(Date.now())
}
} else {
//= [03] 오류가 발생했습니다.\n계속 오류가 발생하면 고객센터로 문의해 주세요.
res = { code: res.code, message: res.message || '' }
console.log(
'[Failure] /composables/promotion/usePreregist.setPreregist: ',
res
)
res.code = -99999 // else 알럿 띄우기 용 세팅
}
} else {
res = { code: -99999, message: '' }
console.log(
'[Failure] /composables/promotion/usePreregist.setPreregist: ',
res
)
// 응답 검증
if (!res) {
// eslint-disable-next-line no-console
console.error('[usePreregist].setPreregist: Empty response')
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN)
}
} catch (e) {
console.error(
'[Exception] /composables/promotion/usePreregist.setPreregist: ',
e
// 정규화된 에러 코드
const normalizedCode = normalizeAuthError(res.code)
// 성공 케이스
if (normalizedCode === PREREGIST_ERROR_CODE.SUCCESS) {
setPreregistDate(res.message ? Number(res.message) : Date.now())
return res
}
// 이미 등록된 경우 (날짜 업데이트)
if (normalizedCode === PREREGIST_ERROR_CODE.ALREADY_REGISTERED) {
setPreregistDate(res.message ? Number(res.message) : Date.now())
return createErrorResponse(normalizedCode, res.message)
}
// 예상된 에러 케이스
const expectedErrors: number[] = [
PREREGIST_ERROR_CODE.LOGIN_REQUIRED,
PREREGIST_ERROR_CODE.NOT_PERIOD,
// PREREGIST_ERROR_CODE.BIRTH_DATE_REQUIRED,
PREREGIST_ERROR_CODE.AGE_RESTRICTION,
PREREGIST_ERROR_CODE.REQUIRED_TERMS,
]
if (expectedErrors.includes(normalizedCode)) {
return createErrorResponse(normalizedCode, res.message)
}
// 예상치 못한 에러
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[usePreregist].setPreregist: Unexpected error', res)
}
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN, res.message)
} catch (error) {
// eslint-disable-next-line no-console
console.error('[usePreregist].setPreregist: Exception', error)
return createErrorResponse(
PREREGIST_ERROR_CODE.UNKNOWN,
error instanceof Error ? error.message : String(error)
)
res = {
code: -99999,
message: `${e}`,
}
}
return res
}
return {
// GDS state & methods
isKorea,
isTaiwanHongKongMacau,
isNorthAmerica,
countryCode,
countryDialingCode,
preregistDate,
checkCountryByIp,
// Preregist state & computed
countryDialingCode,
preregistDate: readonly(preregistDate),
// Preregist methods
getPreregist,
setPreregist,
}
}
export { usePreregist }
export { usePreregist, PREREGIST_ERROR_CODE }

View File

@@ -39,9 +39,9 @@ const OS_TYPE_MAP: Record<string, Platform[]> = {
'3': ['google_play', 'app_store'],
}
const preregistModalRef = ref<{ handlePreregist: () => Promise<void> } | null>(
null
)
const preregistModalRef = ref<{
handleOpenPreregist: () => Promise<void>
} | null>(null)
// Preregist Section
const preregistCode = computed(
@@ -209,7 +209,7 @@ const splideOptions = computed(() => {
// Handler
const handlePreregistClick = () => {
preregistModalRef.value?.handlePreregist()
preregistModalRef.value?.handleOpenPreregist()
}
</script>