Merge branch 'feature/202501107-all' into feature/20251001-gil

This commit is contained in:
“hyeonggkim”
2025-11-07 17:26:03 +09:00
107 changed files with 2257 additions and 587 deletions

View File

@@ -20,6 +20,10 @@ STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/laun
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe
STOVE_LOGIN_URL=https://accounts-dev.onstove.com/login STOVE_LOGIN_URL=https://accounts-dev.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ###################################################################### # Log Tracking ######################################################################
# 81plug # 81plug
STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/dev/svc_81plug.min.js STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/dev/svc_81plug.min.js

View File

@@ -20,6 +20,9 @@ STOVE_LAUNCHER_SCRIPT=https://js-cdn.onstove.com/libs/stove-js-service/latest/la
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-live-dl.game.playstove.com/game/lcs/STOVESetup.exe STOVE_CLIENT_DOWNLOAD_URL=https://sgs-live-dl.game.playstove.com/game/lcs/STOVESetup.exe
STOVE_LOGIN_URL=https://accounts.onstove.com/login STOVE_LOGIN_URL=https://accounts.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ###################################################################### # Log Tracking ######################################################################
# 81plug # 81plug
STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/live/svc_81plug.min.js STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/live/svc_81plug.min.js

View File

@@ -20,6 +20,9 @@ STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/laun
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe
STOVE_LOGIN_URL=https://accounts-qa.onstove.com/login STOVE_LOGIN_URL=https://accounts-qa.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ###################################################################### # Log Tracking ######################################################################
# 81plug # 81plug
STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/qa/svc_81plug.min.js STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/qa/svc_81plug.min.js

View File

@@ -20,6 +20,9 @@ STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/laun
STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe STOVE_CLIENT_DOWNLOAD_URL=https://sgs-gate8-dl.game.playstove.com/game/lcs/STOVESetup.exe
STOVE_LOGIN_URL=https://accounts.gate8.com/login STOVE_LOGIN_URL=https://accounts.gate8.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ###################################################################### # Log Tracking ######################################################################
# 81plug # 81plug
STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/sandbox/svc_81plug.min.js STOVE_81PLUG=https://dvudc0gwzz5wc.cloudfront.net/v3.1/sandbox/svc_81plug.min.js

View File

@@ -30,7 +30,7 @@ const getGameDataFromServer = (): GameDataValue | null => {
const setupAllMetaData = (data: GameDataValue) => { const setupAllMetaData = (data: GameDataValue) => {
const meta = data.meta_tag_json ?? ({} as GameDataMetaTag) const meta = data.meta_tag_json ?? ({} as GameDataMetaTag)
const faviconPath = data.favicon_json ?? ({} as GameDataFavicon) const faviconPath = data.favicon_json ?? ({} as GameDataFavicon)
const theme = data.design_theme === 1 ? 'dark' : 'light' const theme = data.design_theme === 1 ? 'light' : 'dark'
// 파비콘 링크 생성 // 파비콘 링크 생성
const faviconLinks = [ const faviconLinks = [

View File

@@ -6,7 +6,11 @@
<clientOnly> <clientOnly>
<!-- 로고 --> <!-- 로고 -->
<div class="inspection-logo"> <div class="inspection-logo">
<img :src="logoImgUrl" alt="logo" class="w-full h-full object-contain" /> <img
:src="logoImgUrl"
alt="logo"
class="w-full h-full object-contain"
/>
</div> </div>
<div class="inspection-content"> <div class="inspection-content">
@@ -18,10 +22,18 @@
<div class="inspection-cards"> <div class="inspection-cards">
<!-- 점검 시간 카드 --> <!-- 점검 시간 카드 -->
<div v-if="webInspectionData" class="inspection-card inspection-time-card"> <div
<h2 class="card-title text-base text-md md:text-lg">{{ tm('Inspection_Maintenance_Time') }}</h2> v-if="webInspectionData"
class="inspection-card inspection-time-card"
>
<h2 class="card-title text-base text-md md:text-lg">
{{ tm('Inspection_Maintenance_Time') }}
</h2>
<div class="inspection-time text-sm md:text-base font-medium"> <div class="inspection-time text-sm md:text-base font-medium">
<div v-dompurify-html="getLocaleTimezone('', '')" class="time-row"></div> <div
v-dompurify-html="getLocaleTimezone('', '')"
class="time-row"
></div>
</div> </div>
</div> </div>
@@ -53,7 +65,6 @@
> >
<span>{{ tm('Inspection_Community_Btn') }}</span> <span>{{ tm('Inspection_Community_Btn') }}</span>
<AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" /> <AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" />
</AtomsButtonVariant> </AtomsButtonVariant>
<AtomsButtonVariant <AtomsButtonVariant
@@ -65,21 +76,36 @@
> >
<span>게임 시작</span> <span>게임 시작</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.3098 1.49172C8.86574 1.28049 7.15098 1.28055 5.70176 1.49167C3.50821 1.81315 1.81986 3.50786 1.49213 5.69168L1.4918 5.69391C1.38564 6.41904 1.33331 7.19181 1.33331 7.99998C1.33331 8.80815 1.3857 9.58136 1.49187 10.3065C1.81362 12.4934 3.50335 14.1805 5.69381 14.5079L5.69577 14.5082C6.42109 14.6143 7.19417 14.6666 7.99717 14.6666C8.80032 14.6666 9.57311 14.6143 10.3035 14.5083L10.3046 14.5082C12.4928 14.1865 14.1802 12.4917 14.5078 10.3083L14.5082 10.3061C14.6143 9.58092 14.6666 8.80815 14.6666 7.99998C14.6666 7.19527 14.6203 6.42148 14.5137 5.69347C14.1921 3.50726 12.4966 1.81316 10.3098 1.49172ZM6.38756 8.95267C6.39301 9.15365 6.40004 9.35195 6.40866 9.54742L6.40968 9.57054C6.41945 9.78867 6.43118 10.0033 6.44489 10.2141C6.45959 10.4433 6.68743 10.5836 6.88293 10.4823C7.22837 10.3029 7.58116 10.1096 7.9413 9.90379C8.20002 9.7541 8.46167 9.6013 8.72479 9.43914C8.98791 9.27854 9.24516 9.11482 9.49505 8.95111C9.84196 8.72502 10.1771 8.49581 10.4961 8.26817C10.6769 8.14031 10.6769 7.85965 10.4961 7.7318C10.1771 7.50415 9.84343 7.2765 9.49505 7.04886C9.24516 6.88514 8.98791 6.72298 8.72479 6.56082C8.46167 6.39866 8.20002 6.2443 7.9413 6.09618C7.58263 5.89036 7.22837 5.69702 6.88293 5.5177C6.68743 5.41636 6.45959 5.55669 6.44489 5.78589C6.43118 5.99671 6.41945 6.21129 6.40968 6.42943L6.40866 6.45254C6.40004 6.64801 6.39301 6.84631 6.38756 7.0473C6.37874 7.3607 6.37433 7.67722 6.37433 7.99998C6.37433 8.32274 6.37874 8.64082 6.38756 8.95267Z" fill="#332C2A"/> width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.3098 1.49172C8.86574 1.28049 7.15098 1.28055 5.70176 1.49167C3.50821 1.81315 1.81986 3.50786 1.49213 5.69168L1.4918 5.69391C1.38564 6.41904 1.33331 7.19181 1.33331 7.99998C1.33331 8.80815 1.3857 9.58136 1.49187 10.3065C1.81362 12.4934 3.50335 14.1805 5.69381 14.5079L5.69577 14.5082C6.42109 14.6143 7.19417 14.6666 7.99717 14.6666C8.80032 14.6666 9.57311 14.6143 10.3035 14.5083L10.3046 14.5082C12.4928 14.1865 14.1802 12.4917 14.5078 10.3083L14.5082 10.3061C14.6143 9.58092 14.6666 8.80815 14.6666 7.99998C14.6666 7.19527 14.6203 6.42148 14.5137 5.69347C14.1921 3.50726 12.4966 1.81316 10.3098 1.49172ZM6.38756 8.95267C6.39301 9.15365 6.40004 9.35195 6.40866 9.54742L6.40968 9.57054C6.41945 9.78867 6.43118 10.0033 6.44489 10.2141C6.45959 10.4433 6.68743 10.5836 6.88293 10.4823C7.22837 10.3029 7.58116 10.1096 7.9413 9.90379C8.20002 9.7541 8.46167 9.6013 8.72479 9.43914C8.98791 9.27854 9.24516 9.11482 9.49505 8.95111C9.84196 8.72502 10.1771 8.49581 10.4961 8.26817C10.6769 8.14031 10.6769 7.85965 10.4961 7.7318C10.1771 7.50415 9.84343 7.2765 9.49505 7.04886C9.24516 6.88514 8.98791 6.72298 8.72479 6.56082C8.46167 6.39866 8.20002 6.2443 7.9413 6.09618C7.58263 5.89036 7.22837 5.69702 6.88293 5.5177C6.68743 5.41636 6.45959 5.55669 6.44489 5.78589C6.43118 5.99671 6.41945 6.21129 6.40968 6.42943L6.40866 6.45254C6.40004 6.64801 6.39301 6.84631 6.38756 7.0473C6.37874 7.3607 6.37433 7.67722 6.37433 7.99998C6.37433 8.32274 6.37874 8.64082 6.38756 8.95267Z"
fill="#332C2A"
/>
</svg> </svg>
</AtomsButtonVariant> </AtomsButtonVariant>
</div> </div>
</div> </div>
<!-- 다운로드 카드 --> <!-- 다운로드 카드 -->
<div v-if="launchingStatus" class="inspection-card inspection-download-card"> <div
v-if="launchingStatus"
class="inspection-card inspection-download-card"
>
<h3 class="card-title text-base md:text-lg"> <h3 class="card-title text-base md:text-lg">
{{ tm('Inspection_Txt_Download') || '게임 다운로드' }} {{ tm('Inspection_Txt_Download') || '게임 다운로드' }}
</h3> </h3>
<div class="flex flex-row gap-3"> <div class="flex flex-row gap-3">
<AtomsButtonLauncher <AtomsButtonLauncher
v-for="(btn, index) in enabledMarkets" :key="index" v-for="(btn, index) in enabledMarkets"
:key="index"
:class="getButtonClass(btn.platform)" :class="getButtonClass(btn.platform)"
class="h-[48px]" class="h-[48px]"
:platform="btn.platform as any" :platform="btn.platform as any"
@@ -96,11 +122,10 @@
</div> </div>
</clientOnly> </clientOnly>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { globalDateFormat } from '@seed-next/date'; import { globalDateFormat } from '@seed-next/date'
import { useCheckGameStart } from '#layers/composables/useGameStart' import { useCheckGameStart } from '#layers/composables/useGameStart'
const config = useRuntimeConfig() const config = useRuntimeConfig()
@@ -157,7 +182,6 @@ const launchingStatus = computed(() => {
return webInspectionData.value?.launching_status return webInspectionData.value?.launching_status
}) })
// const market_json = { // const market_json = {
// pc: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 }, // pc: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 },
// app_store: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 }, // app_store: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 },
@@ -166,13 +190,17 @@ const launchingStatus = computed(() => {
//gameData.value.market_json 값 중 use_yn === 1 인 항목만 배열로 변환 //gameData.value.market_json 값 중 use_yn === 1 인 항목만 배열로 변환
const enabledMarkets = computed(() => { const enabledMarkets = computed(() => {
return Object.entries(gameData.value.market_json) return (
Object.entries(gameData.value.market_json)
// return Object.entries(market_json) // return Object.entries(market_json)
.filter(([, info]: [string, any]) => info && info.use_yn === 1) .filter(([, info]: [string, any]) => info && info.use_yn === 1)
.map(([platform, info]: [string, any]) => ({ platform, url: info.url as string })) .map(([platform, info]: [string, any]) => ({
platform,
url: info.url as string,
}))
)
}) })
const logoImgUrl = computed(() => { const logoImgUrl = computed(() => {
const currentLocale = locale.value || 'ko' const currentLocale = locale.value || 'ko'
const localeData = (webInspectionData.value as any)?.[currentLocale] const localeData = (webInspectionData.value as any)?.[currentLocale]
@@ -228,15 +256,14 @@ const handleGameStart = () => {
definePageMeta({ definePageMeta({
middleware: ['inspection'], middleware: ['inspection'],
layout: 'only-stove', layout: 'only-stove',
showLoading: false showLoading: false,
}) })
</script> </script>
<style scoped> <style scoped>
.inspection-section { .inspection-section {
@apply flex flex-col items-center gap-10 px-10 py-[120px] pb-[200px] min-h-[calc(100vh-48px)]; @apply flex flex-col items-center gap-10 px-10 py-[120px] pb-[200px] min-h-[calc(100vh-48px)];
background-color: #F0F0F0; background-color: #f0f0f0;
} }
.inspection-logo { .inspection-logo {
@@ -265,7 +292,6 @@ definePageMeta({
.inspection-time-card { .inspection-time-card {
@apply flex flex-col items-center gap-4; @apply flex flex-col items-center gap-4;
} }
.card-title { .card-title {
@@ -310,7 +336,6 @@ definePageMeta({
@apply flex items-center justify-center gap-1 px-6 md:px-8 w-auto h-10 md:h-12 rounded-lg; @apply flex items-center justify-center gap-1 px-6 md:px-8 w-auto h-10 md:h-12 rounded-lg;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.inspection-btn span { .inspection-btn span {

View File

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

@@ -3,19 +3,19 @@
.size-large { .size-large {
/* height: 64px */ /* height: 64px */
@apply px-10 h-16 text-lg rounded-lg @apply px-10 h-16 text-lg rounded-lg
before:rounded after:rounded; before:rounded-lg after:rounded-lg;
} }
.size-medium { .size-medium {
/* height: 56px */ /* height: 56px */
@apply px-10 h-14 text-base rounded-lg @apply px-10 h-14 text-base rounded-lg
before:rounded after:rounded; before:rounded-lg after:rounded-lg;
} }
.size-small { .size-small {
/* height: 48px */ /* height: 48px */
@apply px-10 h-12 text-sm rounded-lg @apply px-10 h-12 text-sm rounded-lg
before:rounded after:rounded; before:rounded-lg after:rounded-lg;
} }
.size-extra-small { .size-extra-small {

View File

@@ -36,7 +36,7 @@
@apply line-clamp-2 text-[16px] font-[500] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-1 md:text-[24px] md:leading-[34px]; @apply line-clamp-2 text-[16px] font-[500] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-1 md:text-[24px] md:leading-[34px];
} }
.title-sm { .title-sm {
@apply text-[15px] font-[500] leading-[24px] tracking-[-0.45px] md:text-[20px] md:leading-[30px] md:tracking-[-0.6px]; @apply text-[15px] font-[500] leading-[24px] tracking-[-0.45px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:text-[20px] md:leading-[30px] md:tracking-[-0.6px];
} }
.title-xs { .title-xs {
@apply text-[14px] font-[500] leading-[20px] tracking-[-0.42px] md:text-[18px] md:leading-[26px] md:tracking-[-0.54px]; @apply text-[14px] font-[500] leading-[20px] tracking-[-0.42px] md:text-[18px] md:leading-[26px] md:tracking-[-0.54px];

View File

@@ -1,7 +1,7 @@
/* Button Size Classes */ /* Button Size Classes */
@layer components { @layer components {
.modal-wrap { .modal-wrap {
@apply fixed inset-0 flex p-5 z-[800]; @apply overflow-auto fixed inset-0 flex p-5 z-[800];
} }
.modal-wrap.dimmed { .modal-wrap.dimmed {

View File

@@ -22,7 +22,8 @@
@apply hidden md:block; @apply hidden md:block;
} }
.splide-arrow { .splide-arrow {
@apply absolute top-1/2 w-[48px] h-[48px] bg-cover bg-center bg-no-repeat -translate-y-1/2 cursor-pointer z-[5] @apply absolute top-1/2 w-[40px] h-[40px] bg-cover bg-center bg-no-repeat -translate-y-1/2 cursor-pointer z-[5]
md:w-[40px] md:h-[40px]
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:rounded-full after:bg-white after:transition-opacity after:duration-300 after:ease-in-out after:opacity-0 after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:rounded-full after:bg-white after:transition-opacity after:duration-300 after:ease-in-out after:opacity-0
hover:after:opacity-10; hover:after:opacity-10;
} }

View File

@@ -0,0 +1,44 @@
/**
* 국가 번호 정의
*/
const countryDialingCodes: Record<string, string> = {
KR: '82', // 대한민국
US: '1', // 미국
GB: '44',
CA: '1', // 캐나다
AU: '61',
IE: '353',
NZ: '64',
ZA: '27',
IN: '91',
SG: '65',
PH: '63',
JP: '81',
DE: '49',
AT: '43',
CH: '41',
LI: '423',
FR: '33',
BE: '32',
LU: '352',
MC: '377',
PT: '351',
BR: '55',
ES: '34',
MX: '52',
AR: '54',
CO: '57',
PE: '51',
VE: '58',
CL: '56',
EC: '593',
GT: '502',
CU: '53',
TH: '66',
TW: '886', // 대만
HK: '852', // 홍콩
MO: '853', // 마카오
CN: '86',
}
export { countryDialingCodes }

View File

@@ -22,9 +22,11 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const { gameData } = useGameDataStore() const gameDataStore = useGameDataStore()
const { isProcessing, validateLauncher } = useCheckGameStart() const { isProcessing, validateLauncher } = useCheckGameStart()
const { gameData } = storeToRefs(gameDataStore)
const PLATFORM_ICON_MAP: Record<Platform, string> = { const PLATFORM_ICON_MAP: Record<Platform, string> = {
google_play: 'AtomsIconsLogoGoogle', google_play: 'AtomsIconsLogoGoogle',
app_store: 'AtomsIconsLogoApple', app_store: 'AtomsIconsLogoApple',
@@ -40,7 +42,7 @@ const DUP_IMAGE_MAP: Record<Platform, string> = {
} as const } as const
const componentTag = computed(() => { const componentTag = computed(() => {
if (props.platform === 'stove') { if (props.type !== 'duplication' && props.platform === 'stove') {
return 'a' return 'a'
} }
return 'button' return 'button'
@@ -75,7 +77,7 @@ const handleClick = () => {
return return
} }
const url = gameData?.market_json[props.platform]?.url const url = gameData.value?.market_json[props.platform]?.url
if (url) window.open(url, '_blank') if (url) window.open(url, '_blank')
} }
</script> </script>

View File

@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
const showSnsList = ref(false) const showSnsList = ref(false)
const isForceClosed = ref(false)
const { gameData } = useGameDataStore() const gameDataStore = useGameDataStore()
const modalStore = useModalStore() const modalStore = useModalStore()
const { gameData } = storeToRefs(gameDataStore)
const { handleOpenToast } = modalStore const { handleOpenToast } = modalStore
const snsBackgroundColor = computed(() => { const snsBackgroundColor = computed(() => {
const colorData = gameData?.comm_sns_bg_color_json?.display const colorData = gameData.value?.comm_sns_bg_color_json?.display
const colorCode = getColorCode({ const colorCode = getColorCode({
colorName: colorData?.color_name, colorName: colorData?.color_name,
colorCode: colorData?.color_code, colorCode: colorData?.color_code,
@@ -16,27 +16,15 @@ const snsBackgroundColor = computed(() => {
return colorCode return colorCode
}) })
const snsList = computed(() => { const snsList = computed(() => {
return gameData?.sns_json return gameData.value?.sns_json
}) })
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (isForceClosed.value) return
showSnsList.value = true showSnsList.value = true
} }
const handleMouseLeave = () => {
if (isForceClosed.value) return
showSnsList.value = false
}
const handleForceClose = () => { const handleForceClose = () => {
isForceClosed.value = true
showSnsList.value = false showSnsList.value = false
// 일정 시간 뒤 다시 hover 가능하도록 초기화
setTimeout(() => {
isForceClosed.value = false
}, 500)
} }
const handleCopy = async () => { const handleCopy = async () => {
@@ -53,14 +41,12 @@ const handleCopy = async () => {
</script> </script>
<template> <template>
<div <div v-if="Object.keys(snsList).length > 0" class="sns-container">
v-if="Object.keys(snsList).length > 0" <button
class="sns-container" class="btn-sns"
@mouseenter="handleMouseEnter" :style="{ backgroundColor: snsBackgroundColor }"
@mouseleave="handleMouseLeave"
@click="handleMouseEnter" @click="handleMouseEnter"
> >
<button class="btn-sns" :style="{ backgroundColor: snsBackgroundColor }">
<AtomsIconsShareLine class="icon-share" /> <AtomsIconsShareLine class="icon-share" />
<span class="sr-only">sns</span> <span class="sr-only">sns</span>
</button> </button>

View File

@@ -68,6 +68,7 @@ const componentProps = computed(() => {
:style="{ :style="{
backgroundColor: props.backgroundColor, backgroundColor: props.backgroundColor,
color: props.textColor, color: props.textColor,
'--text-color': props.textColor,
}" }"
> >
<span class="btn-content"> <span class="btn-content">
@@ -100,6 +101,9 @@ const componentProps = computed(() => {
after:bg-[var(--text-color)] after:opacity-20 after:z-[2]; after:bg-[var(--text-color)] after:opacity-20 after:z-[2];
} }
.btn-base:disabled .btn-content {
@apply opacity-50;
}
.btn-base .btn-content { .btn-base .btn-content {
@apply relative flex items-center gap-1 z-[1]; @apply relative flex items-center gap-1 z-[1];
} }

View File

@@ -2,27 +2,37 @@
interface Props { interface Props {
src: string | { pc?: string; mo?: string } src: string | { pc?: string; mo?: string }
alt?: string alt?: string
imageType?: 'common' | 'game'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
alt: 'image', alt: 'image',
imageType: 'game',
}) })
const isDev = process.env.NODE_ENV === 'development'
const rootPath = isDev ? '' : '/templates/brand'
const isResponsiveMode = computed(() => { const isResponsiveMode = computed(() => {
return typeof props.src === 'object' && !!props.src.pc && !!props.src.mo return typeof props.src === 'object' && !!props.src.pc && !!props.src.mo
}) })
const imagePaths = computed(() => { const imagePaths = computed(() => {
if (typeof props.src === 'string') { if (typeof props.src === 'string') {
const resolved = getImageHost(`${rootPath}${props.src}`) const resolved = getImageHost(props.src, {
return { pc: resolved, mo: '' } imageType: props.imageType,
})
return { pc: '', mo: resolved }
} }
return { return {
pc: props.src.pc ? getImageHost(`${rootPath}${props.src.pc}`) : '', pc: props.src.pc
mo: props.src.mo ? getImageHost(`${rootPath}${props.src.mo}`) : '', ? getImageHost(props.src.pc, {
imageType: props.imageType,
})
: '',
mo: props.src.mo
? getImageHost(props.src.mo, {
imageType: props.imageType,
})
: '',
} }
}) })
</script> </script>
@@ -34,11 +44,5 @@ const imagePaths = computed(() => {
<img :src="imagePaths.pc" :alt="alt" v-bind="$attrs" loading="lazy" /> <img :src="imagePaths.pc" :alt="alt" v-bind="$attrs" loading="lazy" />
</picture> </picture>
<img <img v-else :src="imagePaths.mo" :alt="alt" v-bind="$attrs" loading="lazy" />
v-else
:src="imagePaths.pc || imagePaths.mo"
:alt="alt"
v-bind="$attrs"
loading="lazy"
/>
</template> </template>

View File

@@ -16,10 +16,10 @@ withDefaults(defineProps<Props>(), {
:width="size" :width="size"
:height="size" :height="size"
viewBox="0 0 12 12" viewBox="0 0 12 12"
:fill="color"
> >
<path <path
d="M5.29499 7.715L2.39999 4.875C2.07499 4.555 2.29999 4 2.75999 4L9.23499 4C9.69499 4 9.91999 4.555 9.59499 4.875L6.69999 7.715C6.30999 8.095 5.68999 8.095 5.29999 7.715H5.29499Z" d="M5.29499 7.715L2.39999 4.875C2.07499 4.555 2.29999 4 2.75999 4L9.23499 4C9.69499 4 9.91999 4.555 9.59499 4.875L6.69999 7.715C6.30999 8.095 5.68999 8.095 5.29999 7.715H5.29499Z"
:fill="color"
/> />
</svg> </svg>
</template> </template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 16,
color: '#7F7F7F',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
:fill="color"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.86201 5.19526C3.12236 4.93491 3.54447 4.93491 3.80482 5.19526L8.00008 9.39052L12.1953 5.19526C12.4557 4.93491 12.8778 4.93491 13.1382 5.19526C13.3985 5.45561 13.3985 5.87772 13.1382 6.13807L8.47149 10.8047C8.21114 11.0651 7.78903 11.0651 7.52868 10.8047L2.86201 6.13807C2.60166 5.87772 2.60166 5.45561 2.86201 5.19526Z"
/>
</svg>
</template>

View File

@@ -16,12 +16,12 @@ withDefaults(defineProps<Props>(), {
:width="size" :width="size"
:height="size" :height="size"
viewBox="0 0 20 20" viewBox="0 0 20 20"
:fill="color"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
clip-rule="evenodd" clip-rule="evenodd"
d="M6.91073 3.57757C6.5853 3.90301 6.5853 4.43065 6.91073 4.75609L12.1548 10.0002L6.91073 15.2442C6.5853 15.5697 6.5853 16.0973 6.91073 16.4228C7.23617 16.7482 7.76381 16.7482 8.08924 16.4228L13.9226 10.5894C14.248 10.264 14.248 9.73634 13.9226 9.41091L8.08924 3.57757C7.76381 3.25214 7.23617 3.25214 6.91073 3.57757Z" d="M6.91073 3.57757C6.5853 3.90301 6.5853 4.43065 6.91073 4.75609L12.1548 10.0002L6.91073 15.2442C6.5853 15.5697 6.5853 16.0973 6.91073 16.4228C7.23617 16.7482 7.76381 16.7482 8.08924 16.4228L13.9226 10.5894C14.248 10.264 14.248 9.73634 13.9226 9.41091L8.08924 3.57757C7.76381 3.25214 7.23617 3.25214 6.91073 3.57757Z"
:fill="color"
/> />
</svg> </svg>
</template> </template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 24,
color: '#666666',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
:fill="color"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M20.5607 5.93934C21.1465 6.52513 21.1465 7.47487 20.5607 8.06066L10.5607 18.0607C10.2652 18.3561 9.85997 18.5149 9.44246 18.4989C9.02495 18.4829 8.63305 18.2934 8.36114 17.9762L3.36114 12.1429C2.82201 11.5139 2.89485 10.5669 3.52384 10.0278C4.15283 9.48865 5.09978 9.56149 5.63891 10.1905L9.58475 14.794L18.4394 5.93934C19.0252 5.35355 19.9749 5.35355 20.5607 5.93934Z"
/>
</svg>
</template>

View File

@@ -16,11 +16,10 @@ withDefaults(defineProps<Props>(), {
:width="size" :width="size"
:height="size" :height="size"
viewBox="0 0 32 32" viewBox="0 0 32 32"
fill="none" :fill="color"
> >
<path <path
d="M26.2768 8.10947C26.7975 7.58877 26.7975 6.74455 26.2768 6.22385C25.7561 5.70315 24.9119 5.70315 24.3912 6.22385L16.0007 14.6144L7.61013 6.22385C7.08943 5.70315 6.24521 5.70315 5.72451 6.22385C5.20381 6.74455 5.20381 7.58877 5.72451 8.10947L14.115 16.5L5.72451 24.8905C5.20381 25.4112 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3856L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4112 26.2768 24.8905L17.8863 16.5L26.2768 8.10947Z" d="M26.2768 8.10947C26.7975 7.58877 26.7975 6.74455 26.2768 6.22385C25.7561 5.70315 24.9119 5.70315 24.3912 6.22385L16.0007 14.6144L7.61013 6.22385C7.08943 5.70315 6.24521 5.70315 5.72451 6.22385C5.20381 6.74455 5.20381 7.58877 5.72451 8.10947L14.115 16.5L5.72451 24.8905C5.20381 25.4112 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3856L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4112 26.2768 24.8905L17.8863 16.5L26.2768 8.10947Z"
:fill="color"
/> />
</svg> </svg>
</template> </template>

View File

@@ -1,6 +1,5 @@
<template> <template>
<div class="select-language" :class="{ 'language-changing': isChanging }"> <div class="select-language" :class="{ 'language-changing': isChanging }">
<button <button
:disabled="isChanging" :disabled="isChanging"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-[#CCCCCC] transition-all duration-300 w-[180px] bg-[#292929] border border-[#595959]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-[#CCCCCC] transition-all duration-300 w-[180px] bg-[#292929] border border-[#595959]"
@@ -8,9 +7,20 @@
@click="toggleDropdown" @click="toggleDropdown"
> >
<!-- 지구본 아이콘 --> <!-- 지구본 아이콘 -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_5964_1685)"> <g clip-path="url(#clip0_5964_1685)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.6666 8.00065C14.6666 11.6825 11.6818 14.6673 7.99992 14.6673C4.31802 14.6673 1.33325 11.6825 1.33325 8.00065C1.33325 4.31875 4.31802 1.33398 7.99992 1.33398C11.6818 1.33398 14.6666 4.31875 14.6666 8.00065ZM6.89756 13.2199C6.03596 11.8504 5.50924 10.2901 5.36895 8.66732H2.70785C2.99033 10.9326 4.69347 12.7567 6.89756 13.2199ZM2.70785 7.33398H5.36895C5.50924 5.71116 6.03596 4.15086 6.89756 2.78138C4.69347 3.24458 2.99033 5.06868 2.70785 7.33398ZM13.292 8.66732C13.0095 10.9326 11.3064 12.7567 9.10228 13.2199C9.96388 11.8504 10.4906 10.2901 10.6309 8.66732H13.292ZM13.292 7.33398C13.0095 5.06868 11.3064 3.24458 9.10228 2.78138C9.96388 4.15086 10.4906 5.71116 10.6309 7.33398H13.292ZM7.99992 12.468C7.28662 11.3201 6.84273 10.0202 6.70801 8.66732H9.29183C9.15711 10.0202 8.71322 11.3201 7.99992 12.468ZM6.70801 7.33398H9.29183C9.15711 5.98112 8.71322 4.68121 7.99992 3.5333C7.28662 4.68121 6.84273 5.98112 6.70801 7.33398Z" fill="#CCCCCC"/> <path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.6666 8.00065C14.6666 11.6825 11.6818 14.6673 7.99992 14.6673C4.31802 14.6673 1.33325 11.6825 1.33325 8.00065C1.33325 4.31875 4.31802 1.33398 7.99992 1.33398C11.6818 1.33398 14.6666 4.31875 14.6666 8.00065ZM6.89756 13.2199C6.03596 11.8504 5.50924 10.2901 5.36895 8.66732H2.70785C2.99033 10.9326 4.69347 12.7567 6.89756 13.2199ZM2.70785 7.33398H5.36895C5.50924 5.71116 6.03596 4.15086 6.89756 2.78138C4.69347 3.24458 2.99033 5.06868 2.70785 7.33398ZM13.292 8.66732C13.0095 10.9326 11.3064 12.7567 9.10228 13.2199C9.96388 11.8504 10.4906 10.2901 10.6309 8.66732H13.292ZM13.292 7.33398C13.0095 5.06868 11.3064 3.24458 9.10228 2.78138C9.96388 4.15086 10.4906 5.71116 10.6309 7.33398H13.292ZM7.99992 12.468C7.28662 11.3201 6.84273 10.0202 6.70801 8.66732H9.29183C9.15711 10.0202 8.71322 11.3201 7.99992 12.468ZM6.70801 7.33398H9.29183C9.15711 5.98112 8.71322 4.68121 7.99992 3.5333C7.28662 4.68121 6.84273 5.98112 6.70801 7.33398Z"
fill="#CCCCCC"
/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_5964_1685"> <clipPath id="clip0_5964_1685">
@@ -22,10 +32,35 @@
{{ isChanging ? '언어 변경 중...' : getLanguageName(selectedLocale) }} {{ isChanging ? '언어 변경 중...' : getLanguageName(selectedLocale) }}
</span> </span>
<!-- 로딩 스피너 --> <!-- 로딩 스피너 -->
<svg v-if="isChanging" class="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<circle cx="12" cy="12" r="10" stroke="#CCCCCC" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416"> v-if="isChanging"
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/> class="w-3 h-3 animate-spin"
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
stroke="#CCCCCC"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="31.416"
stroke-dashoffset="31.416"
>
<animate
attributeName="stroke-dasharray"
dur="2s"
values="0 31.416;15.708 15.708;0 31.416"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dashoffset"
dur="2s"
values="0;-15.708;-31.416"
repeatCount="indefinite"
/>
</circle> </circle>
</svg> </svg>
<!-- 드롭다운 화살표 --> <!-- 드롭다운 화살표 -->
@@ -33,15 +68,25 @@
v-else v-else
class="w-3 h-3 text-gray-300 transition-transform duration-200" class="w-3 h-3 text-gray-300 transition-transform duration-200"
:class="{ 'rotate-180': isDropdownOpen }" :class="{ 'rotate-180': isDropdownOpen }"
viewBox="0 0 12 12" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 12 12"
<path d="M6.69999 4.285L9.59499 7.125C9.91999 7.445 9.69499 8 9.23499 8H2.75999C2.29999 8 2.07499 7.445 2.39999 7.125L5.29499 4.285C5.68499 3.905 6.30499 3.905 6.69499 4.285H6.69999Z" fill="#EBEBEB"/> fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.69999 4.285L9.59499 7.125C9.91999 7.445 9.69499 8 9.23499 8H2.75999C2.29999 8 2.07499 7.445 2.39999 7.125L5.29499 4.285C5.68499 3.905 6.30499 3.905 6.69499 4.285H6.69999Z"
fill="#EBEBEB"
/>
</svg> </svg>
</button> </button>
<div v-if="isDropdownOpen" class="dropdown-menu"> <div v-if="isDropdownOpen" class="dropdown-menu">
<div v-for="localeItem in availableLanguages" :key="localeItem.code" class="dropdown-menu-item"> <div
v-for="localeItem in availableLanguages"
:key="localeItem.code"
class="dropdown-menu-item"
>
<button <button
class="dropdown-menu-item-button" class="dropdown-menu-item-button"
:class="{ 'current': localeItem.code === selectedLocale }" :class="{ current: localeItem.code === selectedLocale }"
@click="selectLanguage(localeItem.code)" @click="selectLanguage(localeItem.code)"
> >
<svg <svg
@@ -52,44 +97,50 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="transition-opacity duration-200" class="transition-opacity duration-200"
> >
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.6339 0.366117C15.1221 0.854272 15.1221 1.64573 14.6339 2.13388L6.30057 10.4672C6.05437 10.7134 5.71664 10.8458 5.36872 10.8324C5.0208 10.8191 4.69421 10.6612 4.46762 10.3968L0.300952 5.53571C-0.148326 5.01155 -0.0876239 4.22243 0.436533 3.77315C0.960691 3.32387 1.74982 3.38458 2.19909 3.90873L5.48729 7.74496L12.8661 0.366117C13.3543 -0.122039 14.1458 -0.122039 14.6339 0.366117Z" fill="#FC4420"/> <path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.6339 0.366117C15.1221 0.854272 15.1221 1.64573 14.6339 2.13388L6.30057 10.4672C6.05437 10.7134 5.71664 10.8458 5.36872 10.8324C5.0208 10.8191 4.69421 10.6612 4.46762 10.3968L0.300952 5.53571C-0.148326 5.01155 -0.0876239 4.22243 0.436533 3.77315C0.960691 3.32387 1.74982 3.38458 2.19909 3.90873L5.48729 7.74496L12.8661 0.366117C13.3543 -0.122039 14.1458 -0.122039 14.6339 0.366117Z"
fill="#FC4420"
/>
</svg> </svg>
<span class="text-sm">{{ localeItem.name }}</span> <span class="text-sm">{{ localeItem.name }}</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const baseDomain = `${config.public.baseDomain}` const baseDomain = `${runtimeConfig.public.baseDomain}`
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore) const { gameData } = storeToRefs(gameDataStore)
// 사용 가능한 언어 목록 // 사용 가능한 언어 목록
const availableLanguages = computed(() => { const availableLanguages = computed(() => {
return gameData.value?.lang_codes?.map(localeCode => ({ return (
gameData.value?.lang_codes?.map(localeCode => ({
code: localeCode, code: localeCode,
name: getLanguageName(localeCode) name: getLanguageName(localeCode),
})) || [{ code: 'ko', name: '한국어' }] })) || [{ code: 'ko', name: '한국어' }]
)
}) })
// 언어 코드를 한국어 이름으로 변환하는 함수 // 언어 코드를 한국어 이름으로 변환하는 함수
const getLanguageName = (localeCode: string) => { const getLanguageName = (localeCode: string) => {
const languageNames: Record<string, string> = { const languageNames: Record<string, string> = {
'ko': '한국어', ko: '한국어',
'en': 'English', en: 'English',
'ja': '日本語', ja: '日本語',
'zh-cn': '简体中文', 'zh-cn': '简体中文',
'zh-tw': '繁體中文', 'zh-tw': '繁體中文',
'es': 'Español', es: 'Español',
'fr': 'Français', fr: 'Français',
'de': 'Deutsch', de: 'Deutsch',
'pt': 'Português', pt: 'Português',
'th': 'ไทย', th: 'ไทย',
'it': 'Italiano' it: 'Italiano',
} }
return languageNames[localeCode] || localeCode return languageNames[localeCode] || localeCode
} }
@@ -133,7 +184,7 @@ const switchLanguage = async () => {
domain: baseDomain, domain: baseDomain,
path: '/', path: '/',
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위) maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
sameSite: 'lax' sameSite: 'lax',
}) })
localeCookie.value = selectedLocale.value.toUpperCase() localeCookie.value = selectedLocale.value.toUpperCase()
@@ -152,7 +203,6 @@ const switchLanguage = async () => {
// 서버 미드웨어를 통해 gameData 갱신을 위해 페이지 새로고침 // 서버 미드웨어를 통해 gameData 갱신을 위해 페이지 새로고침
// 이렇게 하면 서버 미드웨어가 새로운 언어로 gameData를 다시 가져옴 // 이렇게 하면 서버 미드웨어가 새로운 언어로 gameData를 다시 가져옴
} }
} catch { } catch {
// 오류 발생 시 이전 언어로 복원 // 오류 발생 시 이전 언어로 복원
@@ -248,5 +298,4 @@ body {
.dropdown-menu-item-button.current svg { .dropdown-menu-item-button.current svg {
opacity: 1; opacity: 1;
} }
</style> </style>

View File

@@ -5,11 +5,13 @@ let cpHeader: any = null
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const { locale, availableLocales } = useI18n() const { locale, availableLocales } = useI18n()
const { gameData } = useGameDataStore() const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
const stoveInflowPath = runtimeConfig.public.stoveInflowPath const stoveInflowPath = runtimeConfig.public.stoveInflowPath
const stoveGameNo = runtimeConfig.public.stoveGameNo const stoveGameNo = runtimeConfig.public.stoveGameNo
const stoveGnbData = gameData?.stove_gnb_json const stoveGnbData = gameData.value?.stove_gnb_json
const languageCodes = computed(() => { const languageCodes = computed(() => {
if (Array.isArray(availableLocales)) { if (Array.isArray(availableLocales)) {

View File

@@ -14,9 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const imagePaths = computed(() => getImagePaths(props.resourcesData)) const imagePaths = computed(() => getImagePaths(props.resourcesData))
const displayText = computed( const displayText = computed(() => props.resourcesData?.display?.text)
() => props.resourcesData?.display?.text || 'image'
)
const colorName = computed(() => props.resourcesData?.display?.color_name) const colorName = computed(() => props.resourcesData?.display?.color_name)
const colorCode = computed(() => props.resourcesData?.display?.color_code) const colorCode = computed(() => props.resourcesData?.display?.color_code)

View File

@@ -8,7 +8,7 @@ interface props {
} }
const props = withDefaults(defineProps<props>(), { const props = withDefaults(defineProps<props>(), {
isShowDimmed: false, isShowDimmed: true,
isOutsideClose: false, isOutsideClose: false,
}) })
@@ -18,9 +18,9 @@ const { tm } = useI18n()
const isOpen = defineModel<boolean>('isOpen', { default: false }) const isOpen = defineModel<boolean>('isOpen', { default: false })
const setButtonEvent = (event?: () => void | void) => { const setButtonEvent = (event?: () => void) => {
if (typeof event === 'function') { if (event) {
return event() event()
} }
isOpen.value = false isOpen.value = false
} }

View File

@@ -9,7 +9,7 @@ interface props {
} }
const props = withDefaults(defineProps<props>(), { const props = withDefaults(defineProps<props>(), {
isShowDimmed: false, isShowDimmed: true,
isOutsideClose: false, isOutsideClose: false,
}) })

View File

@@ -8,13 +8,18 @@ interface props {
} }
const props = withDefaults(defineProps<props>(), { const props = withDefaults(defineProps<props>(), {
isShowDimmed: false, isShowDimmed: true,
isOutsideClose: false, isOutsideClose: false,
}) })
const emit = defineEmits<{
close: []
}>()
const isOpen = defineModel<boolean>('isOpen', { default: false }) const isOpen = defineModel<boolean>('isOpen', { default: false })
const handleCloseModal = () => { const handleCloseModal = () => {
emit('close')
isOpen.value = false isOpen.value = false
} }

View File

@@ -51,7 +51,7 @@ onUnmounted(() => {
<Transition name="fade"> <Transition name="fade">
<div <div
v-if="isOpen" v-if="isOpen"
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-75 z-[800]" class="modal-wrap dimmed overflow-hidden flex items-center justify-center"
:class="props.modalName" :class="props.modalName"
@click="handleOutsideClick" @click="handleOutsideClick"
> >

View File

@@ -13,6 +13,7 @@ interface Props {
arrows?: boolean arrows?: boolean
pagination?: boolean pagination?: boolean
paginationData?: PageDataResourceGroups paginationData?: PageDataResourceGroups
destroy?: boolean
breakpoints?: ResponsiveOptions['breakpoints'] breakpoints?: ResponsiveOptions['breakpoints']
} }
@@ -23,6 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
drag: true, drag: true,
arrows: true, arrows: true,
pagination: true, pagination: true,
destroy: false,
}) })
const emit = defineEmits(['mounted', 'move', 'arrowClick']) const emit = defineEmits(['mounted', 'move', 'arrowClick'])
@@ -47,9 +49,9 @@ const options = computed((): ResponsiveOptions => {
updateOnMove: true, updateOnMove: true,
autoplay: props.autoplay, autoplay: props.autoplay,
drag: props.drag, drag: props.drag,
trimSpace: false,
arrows: props.arrows, arrows: props.arrows,
pagination: props.pagination, pagination: props.pagination,
destroy: props.destroy,
classes: { classes: {
arrows: 'splide-arrows', arrows: 'splide-arrows',
arrow: 'splide-arrow', arrow: 'splide-arrow',

View File

@@ -62,6 +62,20 @@ const thumbOptions = computed<Options>(() => ({
prev: 'arrow-prev', prev: 'arrow-prev',
next: 'arrow-next', next: 'arrow-next',
}, },
breakpoints: {
[BREAKPOINTS.md - 1]: {
padding: {
left: 40,
right: 40,
},
},
[BREAKPOINTS.sm - 1]: {
padding: {
left: 20,
right: 20,
},
},
},
})) }))
const getThumbnailSrc = (item: PageDataTemplateComponentSet) => { const getThumbnailSrc = (item: PageDataTemplateComponentSet) => {
@@ -158,12 +172,12 @@ onBeforeUnmount(() => {
@apply md:w-[calc(100%-16px)]; @apply md:w-[calc(100%-16px)];
} }
.thumbnail-slide { .thumbnail-slide {
@apply overflow-hidden relative mr-[12px] !border-none rounded-[4px] bg-[var(--pagination-disabled)] md:mr-[16px] md:bg-transparent @apply overflow-hidden relative mr-[12px] !border-none rounded-[4px] bg-[var(--pagination-disabled)] md:mr-[16px]
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:border after:rounded-[4px]; after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:border after:rounded-[4px];
} }
.thumbnail-slide:hover, .thumbnail-slide:hover,
.thumbnail-slide.is-active { .thumbnail-slide.is-active {
@apply bg-[var(--pagination-active)] md:bg-transparent; @apply bg-[var(--pagination-active)];
} }
.thumbnail-slide::after { .thumbnail-slide::after {
@apply border-[var(--pagination-disabled)]; @apply border-[var(--pagination-disabled)];
@@ -194,10 +208,10 @@ onBeforeUnmount(() => {
} }
.thumbnail-carousel.thumbnail-default .thumbnail-slide:hover img, .thumbnail-carousel.thumbnail-default .thumbnail-slide:hover img,
.thumbnail-carousel.thumbnail-default .thumbnail-slide.is-active img { .thumbnail-carousel.thumbnail-default .thumbnail-slide.is-active img {
@apply md:grayscale-0 md:opacity-100; @apply md:grayscale-0;
} }
.thumbnail-carousel.thumbnail-default .thumbnail-slide img { .thumbnail-carousel.thumbnail-default .thumbnail-slide img {
@apply hidden md:block md:grayscale md:opacity-60; @apply hidden md:block md:grayscale;
} }
/* 미디어 버전 스타일 */ /* 미디어 버전 스타일 */
@@ -205,16 +219,15 @@ onBeforeUnmount(() => {
@apply flex flex-col items-center; @apply flex flex-col items-center;
} }
.thumbnail-carousel.thumbnail-media .thumbnail-splide { .thumbnail-carousel.thumbnail-media .thumbnail-splide {
@apply w-screen mt-[20px] mx-[-20px] sm:mx-[-40px] md:w-auto md:max-w-[100%] md:mt-[28px] md:mx-auto md:px-[112px]; @apply max-w-[calc(100%+40px)] mt-[20px] mx-[-20px]
} sm:max-w-[calc(100%+80px)] sm:mx-[-40px]
.thumbnail-carousel.thumbnail-media .thumbnail-splide:deep(.splide__track) { md:max-w-[100%] md:mt-[28px] md:mx-auto md:px-[64px];
@apply !px-[20px] sm:!px-[40px] md:!px-[0];
} }
.thumbnail-carousel.thumbnail-media:deep(.arrow-prev) { .thumbnail-carousel.thumbnail-media:deep(.arrow-prev) {
@apply left-[48px]; @apply left-[0];
} }
.thumbnail-carousel.thumbnail-media:deep(.arrow-next) { .thumbnail-carousel.thumbnail-media:deep(.arrow-next) {
@apply right-[48px]; @apply right-[0];
} }
.thumbnail-carousel.thumbnail-media .thumbnail-slide { .thumbnail-carousel.thumbnail-media .thumbnail-slide {
@apply aspect-[16/9] w-[92px] md:w-[128px]; @apply aspect-[16/9] w-[92px] md:w-[128px];

View File

@@ -333,7 +333,7 @@ onBeforeUnmount(() => {
> >
{{ gnb1depthButtonData?.btn_info?.txt_btn_name }} {{ gnb1depthButtonData?.btn_info?.txt_btn_name }}
</AtomsButtonLauncher> </AtomsButtonLauncher>
<div v-if="gnb2depthButtonData" class="nav-2depth"> <div v-if="gnb2depthButtonData" class="nav-2depth hidden md:block">
<ul> <ul>
<li v-for="(item, key) in gnb2depthButtonData" :key="key"> <li v-for="(item, key) in gnb2depthButtonData" :key="key">
<AtomsButtonLauncher type="custom" :platform="key"> <AtomsButtonLauncher type="custom" :platform="key">

View File

@@ -19,7 +19,7 @@ const mainRef = ref<HTMLElement>()
const { getTemplateComponent } = useTemplateRegistry() const { getTemplateComponent } = useTemplateRegistry()
// 개별 메타 태그 표시 여부 확인 // 개별 메타 태그 표시 여부 확인
const shouldShowMetaTag = computed(() => props.pageData.meta_tag_type === 2) const shouldShowMetaTag = computed(() => props.pageData?.meta_tag_type === 2)
// 템플릿 표시 여부 확인 // 템플릿 표시 여부 확인
const isTemplateVisible = (template: PageDataTemplate): boolean => { const isTemplateVisible = (template: PageDataTemplate): boolean => {
@@ -31,7 +31,7 @@ const isTemplateVisible = (template: PageDataTemplate): boolean => {
// 템플릿 목록 계산 // 템플릿 목록 계산
const visibleTemplates = computed(() => const visibleTemplates = computed(() =>
Object.values(props.pageData.templates).filter(isTemplateVisible) Object.values(props.pageData?.templates).filter(isTemplateVisible)
) )
// SEO 메타 태그 설정 // SEO 메타 태그 설정

View File

@@ -31,7 +31,7 @@ const imageClasses = computed(() => [
props.size === 'contain' ? 'bg-contain' : 'bg-cover', props.size === 'contain' ? 'bg-contain' : 'bg-cover',
]) ])
const gradientClasses = computed(() => [ const gradientClasses = computed(() => [
'absolute bottom-0 left-0 right-0', 'absolute bottom-[-2px] left-[-2px] right-[-2px]',
props.gradient, props.gradient,
]) ])
@@ -45,7 +45,7 @@ watch(videoSrc, () => {
</script> </script>
<template> <template>
<div class="absolute inset-0 w-full h-full"> <div class="overflow-hidden absolute inset-0 w-full h-full">
<!-- 이미지 타입 --> <!-- 이미지 타입 -->
<div <div
v-if="isTypeImage(resourcesData?.resource_type) && imageSrc" v-if="isTypeImage(resourcesData?.resource_type) && imageSrc"

View File

@@ -23,8 +23,5 @@ const handleVideoPlayClick = () => {
</script> </script>
<template> <template>
<AtomsButtonPlay <AtomsButtonPlay @click="handleVideoPlayClick" />
:resources-data="resourcesData"
@click="handleVideoPlayClick"
/>
</template> </template>

View File

@@ -0,0 +1,452 @@
<script setup lang="ts">
import { globalDateFormat } from '@seed-next/date'
import { PREREGIST_ERROR_CODE } from '#layers/composables/usePreregist'
interface Props {
preregistCode?: string
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 {
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 또는 key 반환)
*/
const tm = (key: string): string => {
return props.tm?.(key) ?? 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)
}
return text
}
const toggleMarketing = () => {
isCheckedMarketing.value = !isCheckedMarketing.value
}
const toggleExpand = (event: Event) => {
event.stopPropagation()
isExpandedMarketing.value = !isExpandedMarketing.value
}
/**
* 에러 모달 표시
*/
const showErrorModal = (code: number): void => {
if (!code) return
// 일반 에러 메시지
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: tm('Alert_Error'),
confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => {
window.open(customerServiceUrl, '_blank')
},
})
}
/**
* 토큰 및 사전등록 여부 검증
*/
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
// 사전등록 여부 조회
const result = await getPreregist({
accessToken,
event_code: props.preregistCode,
lang: locale.value,
terms_type: 3,
})
// 사전등록 가능
if (result.code === PREREGIST_ERROR_CODE.NO_DATA) {
return true
}
// 이미 사전등록 완료
if (result.code === PREREGIST_ERROR_CODE.SUCCESS) {
showErrorModal(PREREGIST_ERROR_CODE.ALREADY_REGISTERED)
return false
}
// 기타 오류
showErrorModal(result.code)
return false
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[Preregist.checkValidation]', error)
}
showErrorModal(PREREGIST_ERROR_CODE.UNKNOWN)
return false
}
}
/**
* 사전등록 모달 오픈
*/
const handleOpenPreregist = async (): Promise<void> => {
if (isSubmitting.value) 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 result = await setPreregist({
accessToken: csrGetAccessToken(),
event_code: props.preregistCode,
lang_code: locale.value,
terms_type: 3,
device_type: device.isMobile ? 'mobile' : 'pc',
country_code: countryCode.value || 'KR',
necessary_consent1: 'Y',
necessary_consent2: 'Y',
necessary_consent3: isCheckedMarketing.value ? 'Y' : 'N',
birth_date: '',
})
if (result.code === PREREGIST_ERROR_CODE.SUCCESS) {
currentStep.value = 2
return
}
showErrorModal(result.code)
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[Preregist.handleSubmit]', error)
}
showErrorModal(PREREGIST_ERROR_CODE.UNKNOWN)
} finally {
isSubmitting.value = false
}
}
/**
* 모달 닫기 및 상태 초기화
*/
const handleCloseModal = (): void => {
if (isSubmitting.value) return
currentStep.value = 1
isCheckedMarketing.value = false
isExpandedMarketing.value = false
isSubmitting.value = false
isValidated.value = false // 검증 플래그도 초기화
}
defineExpose({
handleOpenPreregist,
})
</script>
<template>
<BlocksModalLayer
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="handleCloseModal"
>
<!-- Step 1: Terms Agreement -->
<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]"
>
{{ tm('Preregist_Modal_Title01') }}
</h4>
<div
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
>
<span class="font-bold text-[#b2b2b2]">1</span>
<span class="text-[#666666]">/</span>
<span class="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="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"
>
{{ 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)"
>
<AtomsIconsArrowDownLine />
</button>
</div>
<!-- Marketing Detail Content -->
<div
v-if="isExpandedMarketing"
class="mt-4 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3"
>
<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>
<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"
>
{{ tm('Preregist_Btn_Preegist') }}
</AtomsButton>
</div>
</div>
<!-- Step 2: Success -->
<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]"
>
{{ tm('Preregist_Modal_Title02') }}
</h4>
<div
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
>
<span class="font-bold text-[#b2b2b2]">2</span>
<span class="text-[#666666]">/</span>
<span class="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]"
>
{{ globalDateFormat(preregistDate, locale) }}
</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]"
>
{{ tm('Preregist_Agree_News_Complete') }}
</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]"
>
{{ tm('Preregist_Stove_Download') }}
</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);
}
</style>

View File

@@ -39,6 +39,7 @@ export const useResponsiveSrc = () => {
path: PageDataResourceGroupResPath, path: PageDataResourceGroupResPath,
options?: { options?: {
resourcesType?: 'image' | 'video' resourcesType?: 'image' | 'video'
imageType?: 'game' | 'common'
} }
) => { ) => {
const result = getDeviceSrc(path, options) const result = getDeviceSrc(path, options)

View File

@@ -27,7 +27,6 @@ export const useCheckPCSpec = (tm: (key: string) => string) => {
contentText: tm('Download_Alert_InstallGuide'), contentText: tm('Download_Alert_InstallGuide'),
confirmButtonText: tm('Download_Text_Download'), confirmButtonText: tm('Download_Text_Download'),
modalName: 'modal-download', modalName: 'modal-download',
isShowDimmed: true,
confirmButtonEvent: () => { confirmButtonEvent: () => {
location.href = String(setupUrl) location.href = String(setupUrl)
}, },

View File

@@ -4,14 +4,18 @@ import { csrGoStoveLogin } from '#layers/utils/stoveUtil'
export const useCheckGameStart = () => { export const useCheckGameStart = () => {
const { tm } = useI18n() const { tm } = useI18n()
const modalStore = useModalStore()
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const { handleTokenValidation } = useTokenValidation() const { handleTokenValidation } = useTokenValidation()
const { gameData } = storeToRefs(gameDataStore)
const stoveCs = runtimeConfig.public.stoveCs
const isProcessing = ref(false) // 연속 클릭 방지 const isProcessing = ref(false) // 연속 클릭 방지
const isShowCheckLauncher = ref(false) // 런처 실행 로딩 표시 const isShowCheckLauncher = ref(false) // 런처 실행 로딩 표시
const isShowDownloadLauncher = ref(false) // 런처 다운로드 표시 const isShowDownloadLauncher = ref(false) // 런처 다운로드 표시
const customerService = 'https://www.google.com' //[TODO] 고객센터 링크 const customerServiceUrl = `${stoveCs}/${gameData.value?.game_id}`
// 에러 처리 // 에러 처리
const errorHandler = (errorCode: number) => { const errorHandler = (errorCode: number) => {
@@ -48,9 +52,9 @@ export const useCheckGameStart = () => {
// 일시적으로 오류가 발생했습니다. 잠시 후 다시 이용해 주세요. 동일한 현상이 계속 발생할 경우 고객센터로 문의해 주세요. // 일시적으로 오류가 발생했습니다. 잠시 후 다시 이용해 주세요. 동일한 현상이 계속 발생할 경우 고객센터로 문의해 주세요.
modalStore.handleOpenConfirm({ modalStore.handleOpenConfirm({
contentText: tm('Alert_Error'), contentText: tm('Alert_Error'),
confirmButtonText: tm('Text_StoveLogin'), confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => { confirmButtonEvent: () => {
window.open(customerService, '_blank') window.open(customerServiceUrl, '_blank')
}, },
}) })
break break
@@ -87,7 +91,7 @@ export const useCheckGameStart = () => {
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore) const { gameData } = storeToRefs(gameDataStore)
const accessTokenSub = useCookie('SUAT') const accessTokenSub = csrGetAccessToken()
const stoveGameId = gameData.value?.game_id || '' const stoveGameId = gameData.value?.game_id || ''
const nationCookie = useCookie('NNTO').value const nationCookie = useCookie('NNTO').value
const localeCookie = useCookie('LOCALE').value const localeCookie = useCookie('LOCALE').value
@@ -96,7 +100,7 @@ export const useCheckGameStart = () => {
// 토큰 유효성 체크 // 토큰 유효성 체크
const validateTokenResult = await handleTokenValidation( const validateTokenResult = await handleTokenValidation(
accessTokenSub.value || '' accessTokenSub || ''
) )
// 토큰 유효성 체크 실패 시 // 토큰 유효성 체크 실패 시

View File

@@ -0,0 +1,157 @@
import type {
ReqGetGdsClientPolicyTotal,
ResGetGdsClientPolicyTotal,
} from '#layers/types/GdsType'
import { DEFAULT_LOCALE_CODE } from '@/i18n.config'
/**
* GDS
*
* @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 countryCode = ref('')
const isKorea = ref(false)
const isTaiwanHongKongMacau = ref(false)
const isNorthAmerica = ref(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 {
// Reactive state
isKorea: readonly(isKorea),
isTaiwanHongKongMacau: readonly(isTaiwanHongKongMacau),
isNorthAmerica: readonly(isNorthAmerica),
countryCode: readonly(countryCode),
// Methods
checkCountryByIp,
}
}
export { useGds }

View File

@@ -13,9 +13,8 @@ export const useGetGameDataExternal = () => {
const getGameDataExternal = async (req: GameDataRequest) => { const getGameDataExternal = async (req: GameDataRequest) => {
console.log('🚀 ~ getGameDataExternal ~ req:', req) console.log('🚀 ~ getGameDataExternal ~ req:', req)
// const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const config = useRuntimeConfig() const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const stoveApiBaseUrl = config.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/game?game_domain=${req.gameDomain}&lang_code=${req.langCode}` const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/game?game_domain=${req.gameDomain}&lang_code=${req.langCode}`
try { try {
@@ -23,7 +22,7 @@ export const useGetGameDataExternal = () => {
console.log('🚀 ~ getGameDataExternal ~ response:', response) console.log('🚀 ~ getGameDataExternal ~ response:', response)
// FIXME: 테스트용 데이터 --------------------------------------------------- // FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) { /* if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
response.value = { response.value = {
inspection_status: 1, inspection_status: 1,
inspection: { inspection: {

View File

@@ -1,4 +1,7 @@
import type { ReqGameMaintenance, ResGameMaintenance } from '#layers/types/GameMaintenanceType' import type {
ReqGameMaintenance,
ResGameMaintenance,
} from '#layers/types/GameMaintenanceType'
/** /**
* 게임 점검 * 게임 점검
@@ -7,7 +10,7 @@ const useGetGameMaintenance = () => {
const inspectionStore = useInspectionStore() const inspectionStore = useInspectionStore()
const logPrefix = { const logPrefix = {
exception: '[Exception] /composables/useGetGameMaintenance', exception: '[Exception] /composables/useGetGameMaintenance',
failure: '[Failure] /composables/useGetGameMaintenance' failure: '[Failure] /composables/useGetGameMaintenance',
} }
const isGameMaintenance = ref(false) // 게임 서버 점검 여부 const isGameMaintenance = ref(false) // 게임 서버 점검 여부
@@ -20,7 +23,11 @@ const useGetGameMaintenance = () => {
const setGameMaintenanceFalse = () => { const setGameMaintenanceFalse = () => {
setIsGameMaintenance(false) setIsGameMaintenance(false)
inspectionStore.setGameMaintenanceStatus(false) inspectionStore.setGameMaintenanceStatus(false)
inspectionStore.setGameMaintenanceData({ ts_start_date: 0, ts_end_date: 0, detail_link: '' }) inspectionStore.setGameMaintenanceData({
ts_start_date: 0,
ts_end_date: 0,
detail_link: '',
})
} }
/** /**
@@ -45,8 +52,8 @@ const useGetGameMaintenance = () => {
if (res != null && res.code === 0) { if (res != null && res.code === 0) {
// FIXME: 테스트용 데이터 --------------------------------------------------- // FIXME: 테스트용 데이터 ---------------------------------------------------
/* const config = useRuntimeConfig() /* const runtimeConfig = useRuntimeConfig()
if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) { if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
res.value = { res.value = {
total_count: 1, total_count: 1,
list: [ list: [
@@ -65,12 +72,16 @@ const useGetGameMaintenance = () => {
} }
} */ } */
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (Number(res.value?.total_count) > 0 && res.value?.list != null && res.value?.list.length > 0) { if (
Number(res.value?.total_count) > 0 &&
res.value?.list != null &&
res.value?.list.length > 0
) {
setIsGameMaintenance(true) // 서버 1개 이상 점검일 경우 점검 중으로 간주 setIsGameMaintenance(true) // 서버 1개 이상 점검일 경우 점검 중으로 간주
inspectionStore.setGameMaintenanceData({ inspectionStore.setGameMaintenanceData({
ts_start_date: res.value?.list[0].start_at || 0, ts_start_date: res.value?.list[0].start_at || 0,
ts_end_date: res.value?.list[0].end_at || 0, ts_end_date: res.value?.list[0].end_at || 0,
detail_link: res.value?.list[0].languages[0].link || '' detail_link: res.value?.list[0].languages[0].link || '',
}) })
inspectionStore.setGameMaintenanceStatus(true) inspectionStore.setGameMaintenanceStatus(true)
} else { } else {

View File

@@ -1,4 +1,8 @@
import type { WebInspectionData, ReqGetInspectionData, ResGetInspectionData } from '#layers/types/InspectionType' import type {
WebInspectionData,
ReqGetInspectionData,
ResGetInspectionData,
} from '#layers/types/InspectionType'
/** /**
* 웹 점검 * 웹 점검
@@ -7,7 +11,7 @@ export const useGetInspectionDataExternal = () => {
const inspectionStore = useInspectionStore() const inspectionStore = useInspectionStore()
const logPrefix = { const logPrefix = {
exception: '[Exception] /composables/useGetInspectionDataExternal', exception: '[Exception] /composables/useGetInspectionDataExternal',
failure: '[Failure] /composables/useGetInspectionDataExternal' failure: '[Failure] /composables/useGetInspectionDataExternal',
} }
const webInspectionData = ref<WebInspectionData | null>(null) const webInspectionData = ref<WebInspectionData | null>(null)
const isWebInspection = ref(false) // 웹 점검 여부 const isWebInspection = ref(false) // 웹 점검 여부
@@ -24,15 +28,18 @@ export const useGetInspectionDataExternal = () => {
* @description https://wiki.smilegate.net/pages/viewpage.action?pageId=563198067 * @description https://wiki.smilegate.net/pages/viewpage.action?pageId=563198067
*/ */
const getInspectionDataExternal = async (req: ReqGetInspectionData) => { const getInspectionDataExternal = async (req: ReqGetInspectionData) => {
// const config = useRuntimeConfig() // const runtimeConfig = useRuntimeConfig()
const apiUrl = `${req.baseApiUrl}/pub-comm/v3.0/inspection/${req.gameId}` const apiUrl = `${req.baseApiUrl}/pub-comm/v3.0/inspection/${req.gameId}`
try { try {
const response = (await commonFetch('GET', apiUrl)) as ResGetInspectionData const response = (await commonFetch(
console.log("🚀 ~ getInspectionDataExternal ~ response:", response) 'GET',
apiUrl
)) as ResGetInspectionData
console.log('🚀 ~ getInspectionDataExternal ~ response:', response)
// FIXME: 테스트용 데이터 --------------------------------------------------- // FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) { /* if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
response.value = { response.value = {
inspection_status: 1, inspection_status: 1,
inspection: { inspection: {

View File

@@ -0,0 +1,262 @@
import type {
ReqPreorderSelectEvent,
ResPreorderSelectEvent,
ReqPreorderReserveDataUpdate,
ResPreorderReserveDataUpdate,
} from '#layers/types/PreregistType'
import { DEFAULT_LOCALE_CODE } from '@/i18n.config'
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())
// GDS composable
const {
isKorea,
isTaiwanHongKongMacau,
isNorthAmerica,
countryCode,
checkCountryByIp,
} = useGds()
// 국가 번호 조회
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 url = `${stoveApiBaseUrl}/pub-comm/v1.0/Preorder/SelectEvent`
const headers = {
Authorization: `Bearer ${req.accessToken}`,
}
const body = {
event_code: req.event_code,
lang: req.lang || DEFAULT_LOCALE_CODE,
terms_type: req.terms_type,
}
const res = (await commonFetch('POST', url, {
headers,
body,
})) as ResPreorderSelectEvent
// 응답 검증
if (!res) {
// eslint-disable-next-line no-console
console.error('[usePreregist].getPreregist: Empty response')
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN)
}
// 정규화된 에러 코드
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)
)
}
}
/**
* 사전 등록 - 저장
*/
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}`,
}
const body = {
necessary_consent1: req.necessary_consent1,
necessary_consent2: req.necessary_consent2,
necessary_consent3: req.necessary_consent3,
event_code: req.event_code,
terms_type: req.terms_type,
c_num: req.c_num,
lang_code: req.lang_code,
hp: req.hp,
email: req.email,
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,
device_type: req.device_type,
country_code: req.country_code,
country_dialing_code: req.country_dialing_code,
birth_date: req.birth_date,
}
const res = (await commonFetch('POST', url, {
headers,
body,
})) as ResPreorderReserveDataUpdate
// 응답 검증
if (!res) {
// eslint-disable-next-line no-console
console.error('[usePreregist].setPreregist: Empty response')
return createErrorResponse(PREREGIST_ERROR_CODE.UNKNOWN)
}
// 정규화된 에러 코드
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)
)
}
}
return {
// GDS state & methods
isKorea,
isTaiwanHongKongMacau,
isNorthAmerica,
countryCode,
checkCountryByIp,
// Preregist state & computed
countryDialingCode,
preregistDate: readonly(preregistDate),
// Preregist methods
getPreregist,
setPreregist,
}
}
export { usePreregist, PREREGIST_ERROR_CODE }

View File

@@ -19,8 +19,8 @@ export const useResourcesData = () => {
): Promise<OperateComponents | null> => { ): Promise<OperateComponents | null> => {
const { pageSeq, pageVer, pageVerTmplSeq, langCode, q, qc } = params const { pageSeq, pageVer, pageVerTmplSeq, langCode, q, qc } = params
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/operateResources` const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/operateResources`
const queryParams: Record<string, string | number> = { const queryParams: Record<string, string | number> = {
@@ -68,8 +68,8 @@ export const useResourcesData = () => {
size, size,
} = params } = params
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/cwms/v3.0/article_group/${articleGroupCode}/${articleGroupSeq}/article/list` const apiUrl = `${stoveApiBaseUrl}/cwms/v3.0/article_group/${articleGroupCode}/${articleGroupSeq}/article/list`
const queryParams: Record<string, string | number | boolean> = { const queryParams: Record<string, string | number | boolean> = {

View File

@@ -12,6 +12,7 @@ import GrContents01 from '#layers/templates/GrContents01/index.vue'
import FxVideo01 from '#layers/templates/FxVideo01/index.vue' import FxVideo01 from '#layers/templates/FxVideo01/index.vue'
import FxDownload01 from '#layers/templates/FxDownload01/index.vue' import FxDownload01 from '#layers/templates/FxDownload01/index.vue'
import FxSecure01 from '#layers/templates/FxSecure01/index.vue' import FxSecure01 from '#layers/templates/FxSecure01/index.vue'
import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue'
const templateRegistry = { const templateRegistry = {
GR_VISUAL_01: { component: GrVisual01 }, GR_VISUAL_01: { component: GrVisual01 },
@@ -28,6 +29,7 @@ const templateRegistry = {
FX_VIDEO_01: { component: FxVideo01 }, FX_VIDEO_01: { component: FxVideo01 },
FX_DOWNLOAD_01: { component: FxDownload01 }, FX_DOWNLOAD_01: { component: FxDownload01 },
FX_SECURE_01: { component: FxSecure01 }, FX_SECURE_01: { component: FxSecure01 },
FX_PREREGIST_01: { component: FxPreregist01 },
} as const } as const
type TemplateKey = keyof typeof templateRegistry type TemplateKey = keyof typeof templateRegistry

View File

@@ -14,21 +14,23 @@ interface TokenValidationResponse {
} }
export const useTokenValidation = () => { export const useTokenValidation = () => {
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const modalStore = useModalStore() const modalStore = useModalStore()
const gameDataStore = useGameDataStore()
const { tm } = useI18n() const { tm } = useI18n()
const apiBaseUrl = config.public.stoveApiUrl const { gameData } = storeToRefs(gameDataStore)
const customerServiceUrl = 'https://www.google.com' // TODO: 고객센터 링크로 변경
const apiBaseUrl = runtimeConfig.public.stoveApiUrl
const stoveCs = runtimeConfig.public.stoveCs
const customerServiceUrl = `${stoveCs}/${gameData.value?.game_id}`
const isTokenValid = ref(false) const isTokenValid = ref(false)
// 로그인 모달 표시 // 로그인 모달 표시
const showLoginModal = (alertKey: string) => { const showLoginModal = (alertKey: string) => {
modalStore.handleOpenConfirm({ modalStore.handleOpenConfirm({
contentText: tm(alertKey), contentText: tm(alertKey),
isShowDimmed: true,
confirmButtonText: tm('Text_StoveLogin'), confirmButtonText: tm('Text_StoveLogin'),
modalName: 'modal-login',
confirmButtonEvent: () => { confirmButtonEvent: () => {
csrGoStoveLogin() csrGoStoveLogin()
}, },
@@ -39,7 +41,7 @@ export const useTokenValidation = () => {
const showErrorModal = () => { const showErrorModal = () => {
modalStore.handleOpenConfirm({ modalStore.handleOpenConfirm({
contentText: tm('Alert_Error'), contentText: tm('Alert_Error'),
confirmButtonText: tm('Text_StoveLogin'), confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => { confirmButtonEvent: () => {
window.open(customerServiceUrl, '_blank') window.open(customerServiceUrl, '_blank')
}, },

View File

@@ -1,15 +1,7 @@
<script setup lang="ts"> <script setup lang="ts"></script>
console.log('🚀 ~ promotion')
</script>
<template> <template>
<div class="promotion-wrap"> <LayoutsHeader />
<slot /> <slot />
</div> <LayoutsFooter />
</template> </template>
<style scoped>
.promo-wrap {
background-color: tan;
}
</style>

View File

@@ -14,14 +14,14 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const currentLangCode = match ? match[1] : null const currentLangCode = match ? match[1] : null
//현재 url에서 게임 도메인만 추출 //현재 url에서 게임 도메인만 추출
const currentDomain = window.location.hostname; const currentDomain = window.location.hostname
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const req: GameDataRequest = { const req: GameDataRequest = {
gameDomain: `${currentDomain}`, gameDomain: `${currentDomain}`,
langCode: `${currentLangCode}`, langCode: `${currentLangCode}`,
game_alias: '', game_alias: '',
lang_code: `${currentLangCode}`, lang_code: `${currentLangCode}`,
baseApiUrl: `${config.public.stoveApiUrl}`, baseApiUrl: `${runtimeConfig.public.stoveApiUrl}`,
gameId: '', gameId: '',
} }
const { getGameDataExternal } = useGetGameDataExternal() const { getGameDataExternal } = useGetGameDataExternal()

View File

@@ -1,15 +1,15 @@
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async to => {
try { try {
if (import.meta.client) { if (import.meta.client) {
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore) const { gameData } = storeToRefs(gameDataStore)
console.log("🚀 ~ 00000 gameData:", gameData.value) console.log('🚀 ~ 00000 gameData:', gameData.value)
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
// const baseDomain = `${config.public.baseDomain}` // const baseDomain = `${runtimeConfig.public.baseDomain}`
const stoveApiBaseUrl = config.public.stoveApiUrl const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const stoveGameId = gameData.value.game_id const stoveGameId = gameData.value.game_id
// const stoveMaintenanceApiUrl = `${config.public.stoveMaintenanceApiUrl}` // const stoveMaintenanceApiUrl = `${runtimeConfig.public.stoveMaintenanceApiUrl}`
// const localeCookie = useCookie('LOCALE', { // const localeCookie = useCookie('LOCALE', {
// domain: baseDomain // domain: baseDomain
@@ -19,9 +19,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
// localeCookie.value = finalLocale.toUpperCase() // localeCookie.value = finalLocale.toUpperCase()
// 웹 점검 ----- // 웹 점검 -----
const { isWebInspection, getInspectionDataExternal } = useGetInspectionDataExternal() const { isWebInspection, getInspectionDataExternal } =
await getInspectionDataExternal({ baseApiUrl: stoveApiBaseUrl, gameId: stoveGameId }) useGetInspectionDataExternal()
await getInspectionDataExternal({
baseApiUrl: stoveApiBaseUrl,
gameId: stoveGameId,
})
// 게임 점검 ----- // 게임 점검 -----
// const { checkGameMaintenance } = useGetGameMaintenance() // const { checkGameMaintenance } = useGetGameMaintenance()
@@ -32,10 +35,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
// lang: `${finalLocale}`.toLowerCase() // lang: `${finalLocale}`.toLowerCase()
// }) // })
if (isWebInspection.value && !to.path.includes('inspection') && !to.path.includes('api')) { if (
isWebInspection.value &&
!to.path.includes('inspection') &&
!to.path.includes('api')
) {
// 점검 중인 경우 // 점검 중인 경우
return navigateTo(`/${finalLocale}/inspection`, { external: true }) return navigateTo(`/${finalLocale}/inspection`, { external: true })
} else if (!isWebInspection.value && to.path?.indexOf('inspection') !== -1) { } else if (
!isWebInspection.value &&
to.path?.indexOf('inspection') !== -1
) {
// 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트 // 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트
return navigateTo(`/${finalLocale}`, { external: true }) return navigateTo(`/${finalLocale}`, { external: true })
} }

View File

@@ -3,15 +3,14 @@ import { usePageDataStore } from '#layers/stores/usePageDataStore'
import { useGetGameDomain } from '#layers/composables/useGetGameDomain' import { useGetGameDomain } from '#layers/composables/useGetGameDomain'
import { usePathResolver } from '#layers/composables/usePathResolver' import { usePathResolver } from '#layers/composables/usePathResolver'
import type { PageDataResponse } from '#layers/types/api/pageData' import type { PageDataResponse } from '#layers/types/api/pageData'
import type { import type { GameDataValue } from '#layers/types/api/gameData'
GameDataValue,
} from '#layers/types/api/gameData'
export default defineNuxtRouteMiddleware(async (to, _from) => { export default defineNuxtRouteMiddleware(async (to, _from) => {
// [TODO] 하이드레이션 에러 처리
if (!import.meta.client) return if (!import.meta.client) return
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page` const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page`
const store = usePageDataStore() const store = usePageDataStore()
@@ -21,7 +20,12 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue const gameData = gameDataStore.gameData as GameDataValue
const langCode = ssrGetFinalLocale(to.path, headers, gameData?.lang_codes, gameData?.default_lang_code) const langCode = ssrGetFinalLocale(
to.path,
headers,
gameData?.lang_codes,
gameData?.default_lang_code
)
try { try {
// 서버 사이드에서는 스킵 // 서버 사이드에서는 스킵
@@ -51,8 +55,8 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
query: queryParams, query: queryParams,
loading: true, loading: true,
})) as PageDataResponse | null })) as PageDataResponse | null
console.log('🚀 ~ response?.code:', response?.code) console.log('🚀 ~ response?.code:', response?.code)
// if(response?.code === 91003) { // if(response?.code === 91003) {
// throw createError({ // throw createError({
// statusCode: 404, // statusCode: 404,

View File

@@ -2,7 +2,7 @@
* i18n 다국어 로더 플러그인 * i18n 다국어 로더 플러그인
* S3에서 공통 다국어 파일을 로드하여 i18n 메시지에 주입합니다. * S3에서 공통 다국어 파일을 로드하여 i18n 메시지에 주입합니다.
*/ */
export default defineNuxtPlugin(async (nuxtApp) => { export default defineNuxtPlugin(async nuxtApp => {
const $i18n = nuxtApp.$i18n as any const $i18n = nuxtApp.$i18n as any
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
@@ -31,7 +31,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
'zh-cn': 'zh-CN', 'zh-cn': 'zh-CN',
} }
langCodes.forEach((langCode) => { langCodes.forEach(langCode => {
// 로케일 코드 변환 (필요한 경우) // 로케일 코드 변환 (필요한 경우)
const normalizedLangCode = localeMap[langCode] || langCode const normalizedLangCode = localeMap[langCode] || langCode
@@ -47,6 +47,9 @@ export default defineNuxtPlugin(async (nuxtApp) => {
} }
}) })
} catch (error) { } catch (error) {
console.error('[Exception] i18n-loader: Failed to load translations:', error) console.error(
'[Exception] i18n-loader: Failed to load translations:',
error
)
} }
}) })

View File

@@ -1,9 +1,5 @@
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { import { getHeader, getRequestHost, defineEventHandler } from 'h3'
getHeader,
getRequestHost,
defineEventHandler,
} from 'h3'
import { ssrGetFinalLocale } from '../../utils/localeUtil' import { ssrGetFinalLocale } from '../../utils/localeUtil'
import type { GameDataResponse } from '../../types/api/gameData' import type { GameDataResponse } from '../../types/api/gameData'
import type { ResGetInspectionData } from '../../types/InspectionType' import type { ResGetInspectionData } from '../../types/InspectionType'
@@ -17,7 +13,9 @@ import { isStaticFile } from '#layers/utils/commonUtil'
* @param customMaxAge - 커스텀 max-age 값 (초 단위) * @param customMaxAge - 커스텀 max-age 값 (초 단위)
*/ */
function setCacheHeaders( function setCacheHeaders(
event: { node: { res: { setHeader: (name: string, value: string) => void } } }, event: {
node: { res: { setHeader: (name: string, value: string) => void } }
},
cacheMode: 'no-cache' | 'short' | 'medium' | 'default', cacheMode: 'no-cache' | 'short' | 'medium' | 'default',
customMaxAge?: number customMaxAge?: number
): void { ): void {
@@ -59,7 +57,7 @@ function setCacheHeaders(
const cache = new LRUCache({ const cache = new LRUCache({
max: 100, // 캐시에 저장할 최대 항목 수 max: 100, // 캐시에 저장할 최대 항목 수
ttl: 1000 * 30 // 30초 동안 캐시 유지 ttl: 1000 * 30, // 30초 동안 캐시 유지
}) })
/** /**
@@ -74,7 +72,7 @@ function setFinalLocaleCookie(event: any, finalLocale: string, baseDomain: strin
setCookie(event, 'LOCALE', finalLocale.toUpperCase(), { setCookie(event, 'LOCALE', finalLocale.toUpperCase(), {
domain: baseDomain, domain: baseDomain,
path: '/', path: '/',
maxAge: 60 * 60 * 24 * 365 // 1년 (초 단위) maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
}) })
} }
@@ -158,10 +156,10 @@ export default defineEventHandler(async event => {
// } // }
// } // }
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const iBaseApiUrl = `${config.public.stoveApiUrlServer}` const iBaseApiUrl = `${runtimeConfig.public.stoveApiUrlServer}`
const baseDomain = `${config.public.baseDomain}` const baseDomain = `${runtimeConfig.public.baseDomain}`
const stoveApiUrlBaseServer = config.public.stoveApiUrlServer const stoveApiUrlBaseServer = runtimeConfig.public.stoveApiUrlServer
const apiUrl = `${stoveApiUrlBaseServer}/pub-comm/v1.0/template/game` const apiUrl = `${stoveApiUrlBaseServer}/pub-comm/v1.0/template/game`
let initGameData: GameDataResponse | null = null let initGameData: GameDataResponse | null = null
@@ -180,7 +178,6 @@ export default defineEventHandler(async event => {
} }
try { try {
const queryParams: Record<string, string> = { const queryParams: Record<string, string> = {
game_domain: cleanHost || '', game_domain: cleanHost || '',
lang_code: '', lang_code: '',
@@ -193,7 +190,7 @@ export default defineEventHandler(async event => {
initLangCodes = initResponse?.value?.lang_codes || null initLangCodes = initResponse?.value?.lang_codes || null
initDefaultLocale = initResponse?.value?.default_lang_code || null initDefaultLocale = initResponse?.value?.default_lang_code || null
console.log("🚀 ~ 000111 initLangCodes:", initLangCodes) console.log('🚀 ~ 000111 initLangCodes:', initLangCodes)
} catch (error) { } catch (error) {
console.error('init gameData load error:', error) console.error('init gameData load error:', error)
} }
@@ -208,7 +205,12 @@ export default defineEventHandler(async event => {
// 1-2. /inspection 패스 // 1-2. /inspection 패스
if (fullPath.includes('/inspection')) { if (fullPath.includes('/inspection')) {
// 리턴 되기 전 언어 쿠키 세팅 // 리턴 되기 전 언어 쿠키 세팅
finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers, initLangCodes, initDefaultLocale) finalLocale = ssrGetFinalLocale(
event?.node.req.url,
event.node.req.headers,
initLangCodes,
initDefaultLocale
)
setFinalLocaleCookie(event, finalLocale, baseDomain) setFinalLocaleCookie(event, finalLocale, baseDomain)
return return
} }
@@ -250,8 +252,6 @@ export default defineEventHandler(async event => {
setFinalLocaleCookie(event, finalLocale, baseDomain) setFinalLocaleCookie(event, finalLocale, baseDomain)
} }
if (response?.code === 0 && 'value' in response) { if (response?.code === 0 && 'value' in response) {
event.context.gameData = response.value event.context.gameData = response.value
event.context.googleAnalyticsId = response.value?.ga_code event.context.googleAnalyticsId = response.value?.ga_code
@@ -267,28 +267,35 @@ export default defineEventHandler(async event => {
if (response?.value?.game_id) { if (response?.value?.game_id) {
const inspectionApiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${response?.value?.game_id}` const inspectionApiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${response?.value?.game_id}`
// 직접 $fetch 사용 (composable 사용하지 않음) // 직접 $fetch 사용 (composable 사용하지 않음)
const inspectionResponse = await $fetch<ResGetInspectionData>(inspectionApiUrl, { const inspectionResponse = await $fetch<ResGetInspectionData>(
inspectionApiUrl,
{
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
},
} }
}) )
inspectionData = inspectionResponse?.value?.inspection inspectionData = inspectionResponse?.value?.inspection
cache.set(cacheKey, inspectionData) // 캐시에 저장 cache.set(cacheKey, inspectionData) // 캐시에 저장
// console.log("🚀 ~ inspectionData:", inspectionData) // console.log("🚀 ~ inspectionData:", inspectionData)
} }
} }
// 4. 현재 시간과 점검 기간 비교 // 4. 현재 시간과 점검 기간 비교
const currentTime = Date.now() const currentTime = Date.now()
const tsStartDate = inspectionData?.ts_start_date || 0 const tsStartDate = inspectionData?.ts_start_date || 0
const tsEndDate = inspectionData?.ts_end_date || 0 const tsEndDate = inspectionData?.ts_end_date || 0
const timeUntilInspectionSeconds = Math.floor((tsStartDate - currentTime) / 1000) const timeUntilInspectionSeconds = Math.floor(
(tsStartDate - currentTime) / 1000
)
// 5. 점검 상태별 캐시 설정 // 5. 점검 상태별 캐시 설정
if (inspectionData?.inspection_status === 1 && currentTime >= tsStartDate && currentTime <= tsEndDate) { if (
inspectionData?.inspection_status === 1 &&
currentTime >= tsStartDate &&
currentTime <= tsEndDate
) {
/** /**
* 점검 중인 경우 * 점검 중인 경우
* - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ * - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ
@@ -301,6 +308,10 @@ export default defineEventHandler(async event => {
setCacheHeaders(event, 'no-cache') setCacheHeaders(event, 'no-cache')
} }
// 점검 중일 때 IP 필터링 활성화 여부 확인
if (inspectionData?.ip_filter_use_yn === 'Y') {
const clientIP = getTrueClientIp(event.node.req as any)
// 점검 중일 때 IP 필터링 활성화 여부 확인 // 점검 중일 때 IP 필터링 활성화 여부 확인
if (inspectionData?.ip_filter_use_yn === 'Y') { if (inspectionData?.ip_filter_use_yn === 'Y') {
const clientIP = getTrueClientIp(event.node.req as any) const clientIP = getTrueClientIp(event.node.req as any)
@@ -327,6 +338,11 @@ export default defineEventHandler(async event => {
event.node.res.end() event.node.res.end()
} }
} }
} else {
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
}
} else { } else {
/** /**
* 점검이 아닌 경우 * 점검이 아닌 경우
@@ -339,7 +355,7 @@ export default defineEventHandler(async event => {
// 홈 경로: 캐시 없음 // 홈 경로: 캐시 없음
const isHomePath = [ const isHomePath = [
'', '',
'/' '/',
//, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`]) //, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`])
].includes(fullPath) ].includes(fullPath)
@@ -366,9 +382,7 @@ export default defineEventHandler(async event => {
// ------------------------------------------------------------------------------- // -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale) fnLocaleMiddleware(event, finalLocale)
} }
} }
} catch (error) { } catch (error) {
console.error('gameData load error:', error) console.error('gameData load error:', error)
} }

View File

@@ -8,10 +8,6 @@ export const useGameDataStore = defineStore('gameData', () => {
gameData.value = data gameData.value = data
} }
const setLangCode = (data: string) => {
langCode.value = data
}
const clearGameData = () => { const clearGameData = () => {
gameData.value = null gameData.value = null
} }
@@ -20,7 +16,6 @@ export const useGameDataStore = defineStore('gameData', () => {
langCode, langCode,
gameData, gameData,
setGameData, setGameData,
setLangCode,
clearGameData, clearGameData,
} }
}) })

View File

@@ -23,7 +23,7 @@ export const useModalStore = defineStore('modalStore', () => {
} }
const handleOpenAlert = ({ const handleOpenAlert = ({
isShowDimmed = false, isShowDimmed = true,
modalName = '', modalName = '',
isOutsideClose = false, isOutsideClose = false,
contentText, contentText,
@@ -47,7 +47,7 @@ export const useModalStore = defineStore('modalStore', () => {
} }
const handleOpenConfirm = ({ const handleOpenConfirm = ({
isShowDimmed = false, isShowDimmed = true,
modalName = '', modalName = '',
isOutsideClose = false, isOutsideClose = false,
contentText, contentText,

View File

@@ -0,0 +1,402 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import { getComponentGroup, getComponentGroupAry } from '#layers/utils/dataUtil'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { OperateGroupItem } from '#layers/types/api/resourcesData'
import type { Platform } from '#layers/types/components/button'
interface Props {
components: PageDataTemplateComponents
pageVerTmplSeq: number
}
const props = defineProps<Props>()
// Configuration
const runtimeConfig = useRuntimeConfig()
const dataResourcesUrl = runtimeConfig.public.dataResourcesUrl as string
const multilingualFileName = 'STOVE_PUBTEMPLATE_homepage_brand_preregist.json'
// Multilingual
const resultGetMultilingual = await useGetMultilingual({
baseApiUrl: dataResourcesUrl,
fileName: multilingualFileName,
})
const { tm, locale }: any = useI18n({
useScope: 'local',
messages: Object(resultGetMultilingual?.value?.multilingual),
})
const { getOperateResourcesData } = useResourcesData()
const { gameData } = storeToRefs(useGameDataStore())
const { pageData } = storeToRefs(usePageDataStore())
// Constants
const COLOR_INDEX = { BACKGROUND: 0, TEXT: 1 } as const
const OS_TYPE_MAP: Record<string, Platform[]> = {
'1': ['google_play'],
'2': ['app_store'],
'3': ['google_play', 'app_store'],
}
const preregistModalRef = ref<{
handleOpenPreregist: () => Promise<void>
} | null>(null)
// Preregist Section
const preregistCode = computed(
() => getComponentGroup(props.components, 'eventKey')?.display?.text
)
const prdBackgroundData = computed(() =>
getComponentGroup(props.components, 'background')
)
const preMainTitleData = computed(() =>
getComponentGroup(props.components, 'mainTitle')
)
const preSubTitleData = computed(() =>
getComponentGroup(props.components, 'subTitle')
)
const preImgPreregistdData = computed(() =>
getComponentGroup(props.components, 'imgPreregistReward')
)
const preImgSnsData = computed(() =>
getComponentGroup(props.components, 'imgSnsReward')
)
const preDescriptionData = computed(() =>
getComponentGroup(props.components, 'description')
)
// SNS Buttons
const snsButtonsData = computed(() => {
const buttons = getComponentGroupAry(props.components, 'imgSnsButton')
const links = getComponentGroupAry(props.components, 'txtSnsLink')
if (!buttons?.length) return []
return buttons.map((button, index) => ({
image: button,
link: links?.[index]?.display?.text ?? '',
id: button.id ?? `sns-${index}`,
}))
})
// Button Colors
const buttonColors = computed(() => {
const colorData = getComponentGroupAry(
props.components,
'preregistButtonColor'
)
if (!colorData?.length)
return { backgroundColor: undefined, textColor: undefined }
return {
backgroundColor: getColorCode({
colorName: colorData[COLOR_INDEX.BACKGROUND]?.display?.color_name,
colorCode: colorData[COLOR_INDEX.BACKGROUND]?.display?.color_code,
}),
textColor: getColorCode({
colorName: colorData[COLOR_INDEX.TEXT]?.display?.color_name,
colorCode: colorData[COLOR_INDEX.TEXT]?.display?.color_code,
}),
}
})
// Platform Buttons
const platformButtons = computed<Platform[]>(() => {
const osType = String(gameData.value?.os_type ?? '')
return OS_TYPE_MAP[osType] ?? []
})
// Reward Section
const accBackgroundData = computed(() =>
getComponentGroup(props.components, 'backgroundAccReward')
)
const accMainTitleData = computed(() =>
getComponentGroup(props.components, 'mainTitleAccReward')
)
const accSubTitleData = computed(() =>
getComponentGroup(props.components, 'subTitleAccReward')
)
const accRewardTitleData = computed(() =>
getComponentGroup(props.components, 'rewardTitleAccReward')
)
const accDescriptionData = computed(() =>
getComponentGroup(props.components, 'descriptionAccReward')
)
const accPaginationData = computed(() =>
getComponentGroupAry(props.components, 'pagination')
)
// Async Data - 리워드 완료 데이터
const { data: rewardCompletedData } = await useAsyncData(
`fx-preregist-resources-${pageData.value?.page_seq}-${pageData.value?.page_ver}-${props.pageVerTmplSeq}`,
async () => {
const { page_seq, page_ver } = pageData.value ?? {}
if (!page_seq || !page_ver) return []
try {
const operateGroupList = await getOperateResourcesData({
pageSeq: page_seq,
pageVer: page_ver,
pageVerTmplSeq: props.pageVerTmplSeq,
langCode: locale.value,
})
return getComponentContainer(operateGroupList, 'isAccRewardCompleted', {
isGroup: true,
}) as OperateGroupItem[]
} catch (error) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.error('[FxPreregist01] Failed to fetch reward data:', error)
}
return []
}
},
{
default: () => [],
server: false,
}
)
// Reward Images
const rewardImages = computed(() => {
const defaultList = getComponentGroupAry(props.components, 'imgAccReward')
const incompleteList = getComponentGroupAry(
props.components,
'imgAccRewardIncomplete'
)
if (!defaultList?.length) return []
return defaultList.map((defaultItem, index) => ({
id: defaultItem.id ?? `reward-${index}`,
default: defaultItem,
incomplete: incompleteList?.[index] ?? null,
flagType: rewardCompletedData.value?.[index]?.flag_type ?? 0,
}))
})
// Splide Options
const splideOptions = computed(() => {
const length = rewardImages.value.length
return {
type: 'slide' as const,
gap: 16,
arrows: false,
pagination: false,
destroy: true,
breakpoints: {
937: {
destroy: length < 5,
gap: 12,
padding: { left: 40, right: 40 },
},
[BREAKPOINTS.sm - 1]: {
padding: { left: 20, right: 20 },
},
723: {
destroy: length < 4,
},
561: {
destroy: false,
},
},
}
})
// Handler
const handlePreregistClick = () => {
preregistModalRef.value?.handleOpenPreregist()
}
</script>
<template>
<div class="section-container">
<!-- Preregist Section -->
<section class="relative py-[80px] md:py-[120px]">
<WidgetsBackground
v-if="prdBackgroundData"
:resources-data="prdBackgroundData"
/>
<div class="content-standard">
<WidgetsMainTitle
v-if="preMainTitleData"
:resources-data="preMainTitleData"
class="title-xlg"
/>
<WidgetsSubTitle
v-if="preSubTitleData"
:resources-data="preSubTitleData"
class="title-sm mt-2"
/>
<div class="flex flex-col gap-4 mt-8 md:flex-row">
<div v-if="preImgPreregistdData" class="w-full max-w-[446px]">
<AtomsImg
:src="getImagePaths(preImgPreregistdData)"
:alt="preImgPreregistdData?.display?.text"
loading="lazy"
decoding="async"
class="w-full h-full object-contain"
/>
</div>
<div v-if="preImgSnsData" class="relative w-full max-w-[446px]">
<AtomsImg
:src="getImagePaths(preImgSnsData)"
:alt="preImgSnsData?.display?.text"
loading="lazy"
decoding="async"
class="w-full h-full object-contain"
/>
<ul
v-if="snsButtonsData.length"
class="absolute bottom-[20px] left-0 w-full flex items-center justify-center gap-2 md:bottom-[24px] md:gap-3"
>
<li
v-for="btn in snsButtonsData"
:key="btn.id"
class="w-[48px] h-[40px] md:w-[72px] md:h-[56px]"
>
<a :href="btn.link" target="_blank" rel="noopener noreferrer">
<AtomsImg
:src="getImagePaths(btn.image)"
:alt="btn.image?.display?.text"
/>
</a>
</li>
</ul>
</div>
</div>
<div class="flex gap-3 justify-center flex-wrap mt-8 md:gap-2.5">
<AtomsButtonLauncher
type="duplication"
platform="stove"
:background-color="buttonColors.backgroundColor"
:text-color="buttonColors.textColor"
@click="handlePreregistClick"
>
{{ tm('Preregist_Btn_Preegist') }}
</AtomsButtonLauncher>
<AtomsButtonLauncher
v-for="platform in platformButtons"
:key="`preregist-${platform}`"
type="duplication"
:platform="platform"
:background-color="buttonColors.backgroundColor"
:text-color="buttonColors.textColor"
>
{{ tm('Preregist_Btn_Preegist') }}
</AtomsButtonLauncher>
</div>
<WidgetsDescription
v-if="preDescriptionData"
:resources-data="preDescriptionData"
class="mt-8"
/>
</div>
</section>
<!-- Reward Section -->
<section class="relative py-[80px] md:py-[120px]">
<WidgetsBackground
v-if="accBackgroundData"
:resources-data="accBackgroundData"
/>
<div class="content-standard">
<WidgetsMainTitle
v-if="accMainTitleData"
:resources-data="accMainTitleData"
class="title-xlg"
/>
<WidgetsSubTitle
v-if="accSubTitleData"
:resources-data="accSubTitleData"
class="title-sm mt-2"
/>
<WidgetsSubTitle
v-if="accRewardTitleData"
:resources-data="accRewardTitleData"
class="mt-[48px] text-[18px] font-[700] leading-[26px] tracking-[-0.54px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:mt-[72px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px]"
/>
<div
v-if="rewardImages.length"
class="overflow-hidden w-[calc(100%+40px)] min-h-[228px] mt-6 mx-[-20px] sm:w-[calc(100%+80px)] sm:mx-[-40px] md:w-full md:min-h-[281px] md:mt-8 md:mx-auto"
>
<ClientOnly>
<ul
class="hidden md:flex justify-center md:mb-[20px]"
:style="getPaginationClass(accPaginationData)"
>
<li
v-for="(item, index) in rewardImages"
:key="item.id"
:class="[
'flex items-center',
{ 'is-completed': item.flagType === 1 },
]"
>
<span class="progress-bullet"></span>
<div
v-if="index < rewardImages.length - 1"
class="progress-bar"
></div>
</li>
</ul>
<BlocksSlideDefault v-bind="splideOptions">
<SplideSlide
v-for="item in rewardImages"
:key="item.id"
class="w-[162px] h-[228px] md:w-[172px] md:h-[245px]"
>
<AtomsImg
:src="
getImagePaths(
item.flagType === 1 ? item.incomplete : item.default
)
"
:alt="item.default?.display?.text"
loading="lazy"
decoding="async"
class="w-full h-full object-contain"
/>
</SplideSlide>
</BlocksSlideDefault>
</ClientOnly>
</div>
<WidgetsDescription
v-if="accDescriptionData"
:resources-data="accDescriptionData"
class="mt-6 md:mt-8"
/>
</div>
</section>
<WidgetsModalPreregist
ref="preregistModalRef"
:tm="tm"
:preregist-code="preregistCode"
/>
</div>
</template>
<style scoped>
/* destroy되었을 때 (슬라이드 비활성화) 중앙 정렬 */
.splide:not(.is-active):deep(.splide__list) {
@apply flex justify-center gap-3 md:gap-4;
}
.progress-bullet {
@apply block w-3 h-3 rounded-full transition-all duration-300;
background-color: var(--pagination-disabled);
}
.progress-bar {
@apply w-[180px] h-0.5 overflow-hidden;
background-color: var(--pagination-disabled);
}
.is-completed .progress-bullet,
.is-completed .progress-bar {
background-color: var(--pagination-active);
}
</style>

View File

@@ -13,10 +13,10 @@ const props = defineProps<Props>()
const { handleTokenValidation } = useTokenValidation() const { handleTokenValidation } = useTokenValidation()
// Configuration // Configuration
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const dataResourcesUrl = config.public.dataResourcesUrl as string const dataResourcesUrl = runtimeConfig.public.dataResourcesUrl as string
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const multilingualFileName = 'STOVE_PUBTEMPLATE_homepage_brand_secure.json' const multilingualFileName = 'STOVE_PUBTEMPLATE_homepage_brand_secure.json'
const stoveApiBaseUrl = config.public.stoveApiUrl
// Multilingual // Multilingual
const resultGetMultilingual = await useGetMultilingual({ const resultGetMultilingual = await useGetMultilingual({
@@ -33,7 +33,7 @@ const isLogin = ref(false)
const secureSetting = ref({ const secureSetting = ref({
otpLoginYn: 'N', otpLoginYn: 'N',
abroadLoginBlockYn: 'N', abroadLoginBlockYn: 'N',
pcRegisterYn: 'N' pcRegisterYn: 'N',
}) })
// 회원 보안 설정 설정 // 회원 보안 설정 설정
@@ -52,26 +52,28 @@ const checkLoginValidation = async () => {
// 회원 보안 설정 조회 // 회원 보안 설정 조회
const fnGetSecuritySetting = async () => { const fnGetSecuritySetting = async () => {
const accessToken = useCookie('SUAT') const accessToken = useCookie('SUAT')
checkLoginValidation() checkLoginValidation()
const apiBase = `${stoveApiBaseUrl}/auth-secure/v1.0` const apiBase = `${stoveApiBaseUrl}/auth-secure/v1.0`
const headers = { const headers = {
Authorization: `Bearer ${accessToken.value}`, Authorization: `Bearer ${accessToken.value}`,
'Content-Type': 'application/json;charset=UTF-8' 'Content-Type': 'application/json;charset=UTF-8',
} }
try { try {
const result = await commonFetch('GET', `${apiBase}/security/setting`, { headers }) const result = await commonFetch('GET', `${apiBase}/security/setting`, {
headers,
})
if (result?.code === 0 && Array.isArray(result.value)) { if (result?.code === 0 && Array.isArray(result.value)) {
const arrSecure = result.value const arrSecure = result.value
const getValue = (key: string) => arrSecure.find((f: any) => f.key === key)?.value ?? 'N' const getValue = (key: string) =>
arrSecure.find((f: any) => f.key === key)?.value ?? 'N'
secureSetting.value = { secureSetting.value = {
otpLoginYn: getValue('OTP_LOGIN_YN'), otpLoginYn: getValue('OTP_LOGIN_YN'),
abroadLoginBlockYn: getValue('ABROAD_LOGIN_BLOCK_YN'), abroadLoginBlockYn: getValue('ABROAD_LOGIN_BLOCK_YN'),
pcRegisterYn: getValue('PC_REGISTER_YN') pcRegisterYn: getValue('PC_REGISTER_YN'),
} }
} }
} catch (e) { } catch (e) {
@@ -89,7 +91,9 @@ const secureCards = computed(() => {
{ {
id: 'SECURE_CARD_0', id: 'SECURE_CARD_0',
title: tm('Secure_Stove_otp') || '스토브 인증기 (OTP)', title: tm('Secure_Stove_otp') || '스토브 인증기 (OTP)',
description: tm('Secure_Stove_otp_desc') || '스토브 앱으로 인증 후 안전하게 로그인하세요.', description:
tm('Secure_Stove_otp_desc') ||
'스토브 앱으로 인증 후 안전하게 로그인하세요.',
status: secureSetting.value.otpLoginYn, status: secureSetting.value.otpLoginYn,
benefitTitle: tm('Secure_Stove_otp_benefits') || '스토브 OTP 혜택', benefitTitle: tm('Secure_Stove_otp_benefits') || '스토브 OTP 혜택',
benefitDesc: tm('Secure_Defense_bonus_10') || '방어력 +10', benefitDesc: tm('Secure_Defense_bonus_10') || '방어력 +10',
@@ -100,7 +104,9 @@ const secureCards = computed(() => {
{ {
id: 'SECURE_CARD_1', id: 'SECURE_CARD_1',
title: tm('Secure_Block_foreign_login') || '해외 로그인 차단', title: tm('Secure_Block_foreign_login') || '해외 로그인 차단',
description: tm('Secure_Block_foreign_login_desc') || '접속 국가를 제한하여 의심 로그인을 차단해요.', description:
tm('Secure_Block_foreign_login_desc') ||
'접속 국가를 제한하여 의심 로그인을 차단해요.',
status: secureSetting.value.abroadLoginBlockYn, status: secureSetting.value.abroadLoginBlockYn,
benefitTitle: '', benefitTitle: '',
benefitDesc: '', benefitDesc: '',
@@ -111,7 +117,9 @@ const secureCards = computed(() => {
{ {
id: 'SECURE_CARD_2', id: 'SECURE_CARD_2',
title: tm('Secure_Trusted_pc_management') || '지정 PC 관리', title: tm('Secure_Trusted_pc_management') || '지정 PC 관리',
description: tm('Secure_Trusted_pc_desc') || '지정 PC에서만 로그인할 수 있게 설정해 보세요.', description:
tm('Secure_Trusted_pc_desc') ||
'지정 PC에서만 로그인할 수 있게 설정해 보세요.',
status: secureSetting.value.pcRegisterYn, status: secureSetting.value.pcRegisterYn,
benefitTitle: '', benefitTitle: '',
benefitDesc: '', benefitDesc: '',
@@ -126,7 +134,6 @@ const cautionText = computed(() => {
return tm('Secure_Notice_Content') || [] return tm('Secure_Notice_Content') || []
}) })
onMounted(() => { onMounted(() => {
fnGetSecuritySetting() fnGetSecuritySetting()
}) })
@@ -143,12 +150,21 @@ onMounted(() => {
<section class="section-secure bg-[#F0F0F0] pb-50"> <section class="section-secure bg-[#F0F0F0] pb-50">
<div class="section-static content-standa md:max-w-[1300px] mx-auto"> <div class="section-static content-standa md:max-w-[1300px] mx-auto">
<!-- Title Section --> <!-- Title Section -->
<div class="flex flex-col md:flex-row w-full md:items-end justify-between gap-5 mb-6"> <div
<h3 class="text-[#1F1F1F] text-2xl font-bold leading-8 tracking-[-0.72px]"> class="flex flex-col md:flex-row w-full md:items-end justify-between gap-5 mb-6"
>
<h3
class="text-[#1F1F1F] text-2xl font-bold leading-8 tracking-[-0.72px]"
>
{{ tm('Secure_Section_Title') || '보안 서비스' }} {{ tm('Secure_Section_Title') || '보안 서비스' }}
</h3> </h3>
<p class="text-gray-500 text-[14px] font-[400] leading-[24px] tracking-[-0.42px] text-left md:text-right"> <p
{{ tm('Secure_Section_Description') || '*OTP / 해외 로그인 차단 / 지정 PC 관리 설정하고, 로드나인 계정을 보다 안전하게 보호하세요.' }} class="text-gray-500 text-[14px] font-[400] leading-[24px] tracking-[-0.42px] text-left md:text-right"
>
{{
tm('Secure_Section_Description') ||
'*OTP / 해외 로그인 차단 / 지정 PC 관리 설정하고, 로드나인 계정을 보다 안전하게 보호하세요.'
}}
</p> </p>
</div> </div>
@@ -160,7 +176,9 @@ onMounted(() => {
class="flex-1 min-h-[308px] md:min-h-[384px] p-[10px] md:p-4 bg-[#FFFFFF] rounded-2xl flex flex-col gap-3 transition-all duration-300 ease-in-out" class="flex-1 min-h-[308px] md:min-h-[384px] p-[10px] md:p-4 bg-[#FFFFFF] rounded-2xl flex flex-col gap-3 transition-all duration-300 ease-in-out"
> >
<!-- Card Content --> <!-- Card Content -->
<div class="flex-1 p-[10px] md:p-8 flex flex-col gap-[8px] md:gap-3 text-left"> <div
class="flex-1 p-[10px] md:p-8 flex flex-col gap-[8px] md:gap-3 text-left"
>
<!-- Badge --> <!-- Badge -->
<div class="inline-flex"> <div class="inline-flex">
<span <span
@@ -171,17 +189,25 @@ onMounted(() => {
: 'bg-[#EBEBEB] text-[#999999]', : 'bg-[#EBEBEB] text-[#999999]',
]" ]"
> >
{{ card.status === 'Y' ? tm('Secure_Enabled') : tm('Secure_Disabled') }} {{
card.status === 'Y'
? tm('Secure_Enabled')
: tm('Secure_Disabled')
}}
</span> </span>
</div> </div>
<!-- Title --> <!-- Title -->
<h4 class="text-[#1F1F1F] text-[18px] md:text-[24px] font-bold leading-[26px] md:leading-[34px] tracking-[-0.54px] md:tracking-[-0.72px]"> <h4
class="text-[#1F1F1F] text-[18px] md:text-[24px] font-bold leading-[26px] md:leading-[34px] tracking-[-0.54px] md:tracking-[-0.72px]"
>
{{ card.title }} {{ card.title }}
</h4> </h4>
<!-- Description --> <!-- Description -->
<p class="flex-1 text-[#999999] text-[14px] md:text-base font-[400] leading-[22px] md:leading-[26px] tracking-[-0.42px] md:tracking-[-0.48px]"> <p
class="flex-1 text-[#999999] text-[14px] md:text-base font-[400] leading-[22px] md:leading-[26px] tracking-[-0.42px] md:tracking-[-0.48px]"
>
{{ card.description }} {{ card.description }}
</p> </p>
</div> </div>
@@ -203,7 +229,9 @@ onMounted(() => {
class="w-[48px] h-[48px] bg-[#3C75FF] rounded-[8px] flex items-center justify-center" class="w-[48px] h-[48px] bg-[#3C75FF] rounded-[8px] flex items-center justify-center"
> >
<img <img
:src="getImageHost(card.benefitIcon, { imageType: 'common' })" :src="
getImageHost(card.benefitIcon, { imageType: 'common' })
"
:alt="card.benefitTitle" :alt="card.benefitTitle"
class="w-[48px] h-[48px] object-contain rounded-2xl" class="w-[48px] h-[48px] object-contain rounded-2xl"
loading="lazy" loading="lazy"
@@ -211,7 +239,9 @@ onMounted(() => {
/> />
</div> </div>
<div class="flex-1 flex flex-col text-left"> <div class="flex-1 flex flex-col text-left">
<div class="text-[#3C75FF] text-[14px] md:text-[18px] font-bold leading-[22px] md:leading-[26px] tracking-[-0.42px] md:tracking-[-0.54px]"> <div
class="text-[#3C75FF] text-[14px] md:text-[18px] font-bold leading-[22px] md:leading-[26px] tracking-[-0.42px] md:tracking-[-0.54px]"
>
{{ card.benefitTitle }} {{ card.benefitTitle }}
</div> </div>
<div <div
@@ -230,7 +260,11 @@ onMounted(() => {
button-size="size-small md:size-large" button-size="size-small md:size-large"
background-color="#000000" background-color="#000000"
text-color="#FFFFFF" text-color="#FFFFFF"
@click="isLogin ? handleSecureSetting(card.url) : checkLoginValidation()" @click="
isLogin
? handleSecureSetting(card.url)
: checkLoginValidation()
"
> >
<span>{{ tm('Secure_Action_setup') }}</span> <span>{{ tm('Secure_Action_setup') }}</span>
</AtomsButton> </AtomsButton>
@@ -243,8 +277,19 @@ onMounted(() => {
disabled disabled
> >
<span>{{ tm('Secure_Action_complete') }}</span> <span>{{ tm('Secure_Action_complete') }}</span>
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.4298 2.80644L6.84645 0.240655C7.52385 -0.0802185 8.30948 -0.0802184 8.98688 0.240655L14.4035 2.80645C15.2767 3.22003 15.8333 4.09952 15.8333 5.06564V7.65038C15.8333 13.399 10.6191 16.1288 8.65401 16.9535C8.18024 17.1523 7.6531 17.1523 7.17932 16.9535C5.21423 16.1288 -0.000131724 13.399 2.49573e-09 7.65038L1.11287e-05 5.06566C6.95637e-06 4.09953 0.556675 3.22002 1.4298 2.80644ZM11.4226 7.4063C11.748 7.08086 11.748 6.55323 11.4226 6.22779C11.0972 5.90235 10.5695 5.90235 10.2441 6.22779L7.5 8.97187L6.00592 7.47779C5.68049 7.15235 5.15285 7.15235 4.82741 7.47779C4.50197 7.80323 4.50197 8.33086 4.82741 8.6563L6.91074 10.7396C7.23618 11.0651 7.76382 11.0651 8.08926 10.7396L11.4226 7.4063Z" fill="#999999"/> width="16"
height="18"
viewBox="0 0 16 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.4298 2.80644L6.84645 0.240655C7.52385 -0.0802185 8.30948 -0.0802184 8.98688 0.240655L14.4035 2.80645C15.2767 3.22003 15.8333 4.09952 15.8333 5.06564V7.65038C15.8333 13.399 10.6191 16.1288 8.65401 16.9535C8.18024 17.1523 7.6531 17.1523 7.17932 16.9535C5.21423 16.1288 -0.000131724 13.399 2.49573e-09 7.65038L1.11287e-05 5.06566C6.95637e-06 4.09953 0.556675 3.22002 1.4298 2.80644ZM11.4226 7.4063C11.748 7.08086 11.748 6.55323 11.4226 6.22779C11.0972 5.90235 10.5695 5.90235 10.2441 6.22779L7.5 8.97187L6.00592 7.47779C5.68049 7.15235 5.15285 7.15235 4.82741 7.47779C4.50197 7.80323 4.50197 8.33086 4.82741 8.6563L6.91074 10.7396C7.23618 11.0651 7.76382 11.0651 8.08926 10.7396L11.4226 7.4063Z"
fill="#999999"
/>
</svg> </svg>
</AtomsButton> </AtomsButton>
</div> </div>
@@ -252,8 +297,12 @@ onMounted(() => {
</div> </div>
<!-- Caution Section --> <!-- Caution Section -->
<div class="self-stretch p-8 bg-[#FAFAFA] rounded-2xl flex flex-col gap-3 text-left"> <div
<h5 class="text-[#333333] text-[20px] font-bold leading-[30px] tracking-[-0.6px]"> class="self-stretch p-8 bg-[#FAFAFA] rounded-2xl flex flex-col gap-3 text-left"
>
<h5
class="text-[#333333] text-[20px] font-bold leading-[30px] tracking-[-0.6px]"
>
{{ tm('Secure_Notice') }} {{ tm('Secure_Notice') }}
</h5> </h5>
<ul class="relative flex flex-col items-start justify-start w-full"> <ul class="relative flex flex-col items-start justify-start w-full">
@@ -262,8 +311,7 @@ onMounted(() => {
:key="caution" :key="caution"
v-dompurify-html="caution" v-dompurify-html="caution"
class="relative pl-[22px] before:content-[''] before:absolute before:top-[10px] before:left-[9px] before:w-[3px] before:h-[3px] before:rounded-full before:bg-[#999999] text-[#999999] text-[14px] font-[400] leading-[24px] tracking-[-0.42px]" class="relative pl-[22px] before:content-[''] before:absolute before:top-[10px] before:left-[9px] before:w-[3px] before:h-[3px] before:rounded-full before:bg-[#999999] text-[#999999] text-[14px] font-[400] leading-[24px] tracking-[-0.42px]"
> ></li>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide' import { SplideSlide } from '@splidejs/vue-splide'
import { globalDateFormat } from '@seed-next/date'
import { import {
getComponentGroup, getComponentGroup,
getComponentContainer, getComponentContainer,
} from '#layers/utils/dataUtil' } from '#layers/utils/dataUtil'
import { getYouTubeThumbnail } from '#layers/utils/youtubeUtil' import { getYouTubeThumbnail } from '#layers/utils/youtubeUtil'
import { formatTimestamp } from '#layers/utils/formatUtil'
import type { Splide as SplideType } from '@splidejs/splide' import type { Splide as SplideType } from '@splidejs/splide'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData' import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { OperateGroupItem } from '#layers/types/api/resourcesData' import type { OperateGroupItem } from '#layers/types/api/resourcesData'
@@ -61,7 +61,7 @@ const { data: slideData } = await useAsyncData(
}) })
const mediaList = getComponentContainer(operateGroupList, 'mediaList', { const mediaList = getComponentContainer(operateGroupList, 'mediaList', {
hasGroup: true, isGroup: true,
}) as OperateGroupItem[] }) as OperateGroupItem[]
return mediaList ?? [] return mediaList ?? []
@@ -165,7 +165,7 @@ const handleLoadMoreRecent = () => {
<p <p
class="mt-2 text-[14px] leading-[24px] tracking-[-0.42px] text-[#999] sm:mt-3.5 md:mt-4 md:text-[18px] md:font-[500] md:leading-[26px] md:tracking-[-0.54px] lg:mt-5 lg:text-[20px] lg:leading-[30px] lg:tracking-[-0.6px]" class="mt-2 text-[14px] leading-[24px] tracking-[-0.42px] text-[#999] sm:mt-3.5 md:mt-4 md:text-[18px] md:font-[500] md:leading-[26px] md:tracking-[-0.54px] lg:mt-5 lg:text-[20px] lg:leading-[30px] lg:tracking-[-0.6px]"
> >
{{ formatTimestamp(item.reg_dt, 'YYYY.MM.DD') }} {{ globalDateFormat(item.reg_dt, locale) }}
</p> </p>
</div> </div>
</SplideSlide> </SplideSlide>
@@ -209,7 +209,7 @@ const handleLoadMoreRecent = () => {
<p <p
class="mt-2 text-[14px] leading-[24px] tracking-[-0.42px] text-[#999] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px]" class="mt-2 text-[14px] leading-[24px] tracking-[-0.42px] text-[#999] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px]"
> >
{{ formatTimestamp(item.reg_dt, 'YYYY.MM.DD') }} {{ globalDateFormat(item.reg_dt, locale) }}
</p> </p>
</div> </div>
</li> </li>
@@ -238,11 +238,11 @@ const handleLoadMoreRecent = () => {
@apply block; @apply block;
} }
.splide:deep(.splide-arrows) .splide-arrow { .splide:deep(.splide-arrows) .splide-arrow {
@apply block top-[unset] bottom-[20px] translate-y-0 w-[40px] h-[40px] bg-cover bg-center bg-no-repeat @apply block top-[unset] bottom-[20px] translate-y-0 bg-cover bg-center bg-no-repeat
sm:bottom-[24px] md:bottom-[36px] md:w-[48px] md:h-[48px] lg:bottom-[60px]; sm:bottom-[24px] md:bottom-[36px] lg:bottom-[60px];
} }
.splide:deep(.splide-arrows) .arrow-prev { .splide:deep(.splide-arrows) .arrow-prev {
@apply left-[20px] bg-[image:url('/images/common/btn_system_arrow_rev.png')] @apply left-[20px] bg-[image:url('/images/common/btn_system_arrow_prev.png')]
sm:left-[calc(60.3%+21px)] sm:left-[calc(60.3%+21px)]
md:left-[calc(56%+39px)] md:left-[calc(56%+39px)]
lg:left-[790px]; lg:left-[790px];

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide' import { SplideSlide } from '@splidejs/vue-splide'
import { globalDateFormat } from '@seed-next/date'
import { getComponentGroup } from '#layers/utils/dataUtil' import { getComponentGroup } from '#layers/utils/dataUtil'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData' import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { CwmsArticleItem } from '#layers/types/api/resourcesData' import type { CwmsArticleItem } from '#layers/types/api/resourcesData'
@@ -58,33 +59,42 @@ const { data: slideData } = await useAsyncData(
server: false, server: false,
} }
) )
const slideLength = computed(() => slideData.value.length) const slideLength = computed(() => slideData.value.length ?? 0)
const slideClass = computed(() => ({ const splideOptions = computed(() => {
'is-one-item': slideLength.value === 1, return {
'is-two-items': slideLength.value === 2,
'is-three-items': slideLength.value === 3,
'is-four-items': slideLength.value === 4,
}))
const splideOptions = computed(() => ({
gap: 20, gap: 20,
perPage: 4, perPage: 4,
drag: false, drag: false,
arrows: slideLength.value > 4, arrows: slideLength.value > 4,
pagination: slideLength.value > 4, pagination: slideLength.value > 4,
destroy: slideLength.value <= 4,
breakpoints: { breakpoints: {
[BREAKPOINTS.lg - 1]: { [BREAKPOINTS.lg - 1]: {
perPage: 2, perPage: 2,
arrows: slideLength.value > 2, arrows: slideLength.value > 2,
pagination: slideLength.value > 2, pagination: slideLength.value > 2,
destroy: slideLength.value < 3,
}, },
[BREAKPOINTS.md - 1]: { [BREAKPOINTS.md - 1]: {
drag: true, drag: true,
perPage: 1, perPage: 1,
arrows: false, arrows: false,
pagination: false, pagination: false,
destroy: slideLength.value < 2,
padding: {
left: 40,
right: 40,
}, },
}, },
})) [BREAKPOINTS.sm - 1]: {
padding: {
left: 20,
right: 20,
},
},
},
}
})
const getArticleUrl = (articleId: string) => { const getArticleUrl = (articleId: string) => {
const communityUrl = gameData.value?.url_json?.community const communityUrl = gameData.value?.url_json?.community
@@ -118,7 +128,7 @@ const onArrowClick = (direction, targetIndex) => {
v-if="slideLength > 0" v-if="slideLength > 0"
:slide-item-length="slideLength" :slide-item-length="slideLength"
v-bind="splideOptions" v-bind="splideOptions"
:class="`${slideClass} w-full`" class="w-full"
@arrow-click="onArrowClick" @arrow-click="onArrowClick"
> >
<SplideSlide <SplideSlide
@@ -128,9 +138,7 @@ const onArrowClick = (direction, targetIndex) => {
<div class="slide-inner"> <div class="slide-inner">
<BlocksCardNews <BlocksCardNews
:title="item.title" :title="item.title"
:description=" :description="globalDateFormat(item.create_datetime, locale)"
formatTimestamp(item.create_datetime, 'YYYY.MM.DD')
"
:img-path="getImageHost(item.media_thumbnail_url)" :img-path="getImageHost(item.media_thumbnail_url)"
:url="getArticleUrl(item.article_id)" :url="getArticleUrl(item.article_id)"
link-target="_blank" link-target="_blank"
@@ -158,9 +166,6 @@ const onArrowClick = (direction, targetIndex) => {
.splide { .splide {
@apply mt-[24px] md:max-w-[776px] md:mt-[48px] md:mx-auto md:px-[72px] lg:max-w-[1428px]; @apply mt-[24px] md:max-w-[776px] md:mt-[48px] md:mx-auto md:px-[72px] lg:max-w-[1428px];
} }
.splide:deep(.splide__track) {
@apply !px-[20px] md:max-w-[632px] lg:max-w-[1284px] md:mx-auto md:!px-[0] sm:!px-[40px];
}
.splide:deep(.arrow-prev) { .splide:deep(.arrow-prev) {
@apply top-[calc(50%-28px)] left-[0]; @apply top-[calc(50%-28px)] left-[0];
} }
@@ -168,23 +173,11 @@ const onArrowClick = (direction, targetIndex) => {
@apply top-[calc(50%-28px)] right-[0]; @apply top-[calc(50%-28px)] right-[0];
} }
.slide-inner { .slide-inner {
@apply w-[275px] aspect-[1/1] md:w-[306px] md:box-border; @apply w-[275px] aspect-[1/1] md:w-[306px];
} }
.splide.is-one-item:deep(.splide__track) { /* destroy되었을 때 (슬라이드 비활성화) 중앙 정렬 */
@apply max-w-[315px] sm:max-w-[355px] mx-auto md:max-w-[306px]; .splide:not(.is-active):deep(.splide__list) {
} @apply flex justify-center gap-5;
.splide.is-two-items:deep(.splide__track) {
@apply lg:max-w-[632px];
}
.splide.is-two-items:deep(.splide__list) {
@apply md:!translate-x-0;
}
.splide.is-three-items:deep(.splide__track) {
@apply lg:max-w-[958px];
}
.splide.is-three-items:deep(.splide__list),
.splide.is-four-items:deep(.splide__list) {
@apply lg:!translate-x-0;
} }
</style> </style>

View File

@@ -48,7 +48,8 @@ const buttonListData = computed(() => {
<AtomsImg <AtomsImg
v-if="getImagePaths(item)" v-if="getImagePaths(item)"
:src="getImagePaths(item)" :src="getImagePaths(item)"
:alt="item?.group_label" :alt="item?.display?.text"
class="w-full object-contain"
/> />
</div> </div>
</div> </div>

View File

@@ -166,6 +166,9 @@ const onArrowClick = (direction, targetIndex) => {
.thumbnail-carousel:deep(.thumbnail-slide.is-active) { .thumbnail-carousel:deep(.thumbnail-slide.is-active) {
@apply opacity-100; @apply opacity-100;
} }
.thumbnail-carousel:deep(.thumbnail-splide .splide__track) {
@apply md:max-w-[720px];
}
.main-slide { .main-slide {
@apply relative aspect-[16/9]; @apply relative aspect-[16/9];

View File

@@ -80,9 +80,7 @@ const onArrowClick = (direction, targetIndex) => {
<div class="slide-inner border-line mt-auto"> <div class="slide-inner border-line mt-auto">
<BlocksVisualContent <BlocksVisualContent
:resources-data="getComponentGroup(item, 'imgList')" :resources-data="getComponentGroup(item, 'imgList')"
:page-ver-tmpl-seq="props.pageVerTmplSeq"
object-fit="cover" object-fit="cover"
:alt="getComponentGroup(item, 'subTitle')?.display?.text"
/> />
</div> </div>
</SplideSlide> </SplideSlide>

View File

@@ -92,7 +92,6 @@ const onArrowClick = (direction, targetIndex) => {
<BlocksVisualContent <BlocksVisualContent
:resources-data="getComponentGroup(item, 'imgList')" :resources-data="getComponentGroup(item, 'imgList')"
object-fit="cover" object-fit="cover"
:alt="getComponentGroup(item, 'imgTitle')?.display?.text"
/> />
</div> </div>
</SplideSlide> </SplideSlide>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide' import { SplideSlide } from '@splidejs/vue-splide'
import { globalDateFormat } from '@seed-next/date'
import { import {
getComponentGroup, getComponentGroup,
getComponentContainer, getComponentContainer,
} from '#layers/utils/dataUtil' } from '#layers/utils/dataUtil'
import { formatTimestamp } from '#layers/utils/formatUtil'
import { getImageHost } from '#layers/utils/styleUtil' import { getImageHost } from '#layers/utils/styleUtil'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData' import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { OperateGroupItem } from '#layers/types/api/resourcesData' import type { OperateGroupItem } from '#layers/types/api/resourcesData'
@@ -51,7 +51,7 @@ const { data: slideData } = await useAsyncData(
}) })
const bannerList = getComponentContainer(operateGroupList, 'bannerList', { const bannerList = getComponentContainer(operateGroupList, 'bannerList', {
hasGroup: true, isGroup: true,
minLength: 4, minLength: 4,
}) as OperateGroupItem[] }) as OperateGroupItem[]
@@ -117,7 +117,7 @@ const onArrowClick = direction => {
<SplideSlide v-for="(item, index) in slideData" :key="index"> <SplideSlide v-for="(item, index) in slideData" :key="index">
<BlocksCardNews <BlocksCardNews
:title="item.title" :title="item.title"
:description="formatTimestamp(item.reg_dt, 'YYYY.MM.DD')" :description="globalDateFormat(item.reg_dt, locale)"
:img-path="getImageHost(item.img_path)" :img-path="getImageHost(item.img_path)"
:url="item.url" :url="item.url"
:link-target="item.link_target" :link-target="item.link_target"

34
layers/types/GdsType.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* GDS
*/
interface ReqGetGdsClientPolicyTotal {
policy_grp: string
device_nation?: string
client_lang: string
include_coverages?: boolean
// QA
runType?: string
qc?: string
}
interface DtoGetGdsClientPolicyTotal {
is_default: boolean
nation: string
regulation: string
timezone: string
utc_offset: number
lang: string
coverages: Array<string>
}
interface ResGetGdsClientPolicyTotal {
code?: number
message?: string
res_code?: number
res_message?: string
res_data?: DtoGetGdsClientPolicyTotal
req_data?: ReqGetGdsClientPolicyTotal
}
export type { ReqGetGdsClientPolicyTotal, ResGetGdsClientPolicyTotal }

View File

@@ -0,0 +1,102 @@
interface ReqPreorderSelectEvent {
// Header
accessToken?: string
// Body
event_code: string
lang: string
terms_type: number // 1 : hp / 2 : email / 3 : 스토브 번호
hp?: string
email?: string
}
interface DtoResPreorderSelectEvent {
game_code: number
game_id: string
game_name: string
lang_code: string | null
world_id: string | null
stove_num: number
game_unique_num: number
event_code: string
event_name: string
event_info1: string | null
event_info2: string | null
event_info3: number | null
event_info4: number | null
terms_type: number
event_info_set_terms_type: number | null
hp: string | null
email: string | null
terms_yn: number
terms_count: number
terms_time: string | null
cn_send_time: string | null
cn_send_count: number
under14_terms: number
delete_date: string | null
necessary_consent1: string | null
necessary_consent2: string | null
necessary_consent3: string | null
choice_consent: string | null
con_region: string | null
device_type: string | null
metric_seq: number
email_auto_type: string | null
option01: number | null
option02: string | null
option03: number | null
caller_id: string | null
caller_uuid: string | null
user_ip: string | null
chk_age: number
country_code: string | null
country_dialing_code: number | null
birth_date: string | null
terms_time_long: number
g_server: string | null
c_num: number
}
interface ResPreorderSelectEvent extends CommonResponseType {
value?: DtoResPreorderSelectEvent
}
interface ReqPreorderReserveDataUpdate {
baseApiUrl?: string
// Header
accessToken?: string
// Body
necessary_consent1: string
necessary_consent2: string
necessary_consent3: string
event_code: string
terms_type: number // 1 : hp / 2 : email / 3 : 스토브 번호
c_num?: number
lang_code: string
hp?: string
email?: string
metric_seq?: number //= mcode
g_server?: string
world_id?: string
game_unique_num?: number
event_info1?: string
event_info2?: string
event_info3?: number
event_info4?: number
under14_terms?: string
device_type: string
country_code: string
country_dialing_code?: string
birth_date?: string
}
interface ResPreorderReserveDataUpdate extends CommonResponseType {
value?: number
}
export type {
ReqPreorderSelectEvent,
ResPreorderSelectEvent,
ReqPreorderReserveDataUpdate,
ResPreorderReserveDataUpdate,
}

View File

@@ -52,6 +52,8 @@ export interface GameDataValue {
comm_img_json: GameDataCommImg comm_img_json: GameDataCommImg
market_json: Record<string, { url: string }> market_json: Record<string, { url: string }>
event_banner: GameDataEventBanner event_banner: GameDataEventBanner
os_type: string // 1:AOS, 2:IOS, 3:둘다
platform_type: string // 1:PC, 2:MOBILE, 3:둘다
} }
// ===== 세부 데이터 타입들 ===== // ===== 세부 데이터 타입들 =====

View File

@@ -118,7 +118,7 @@ export type PageDataTemplateComponentSet = PageDataTemplateComponent & {
set_order?: number set_order?: number
} }
// 템플릿 컴포넌트 타입 - 가지 패턴 // 템플릿 컴포넌트 타입 - 가지 패턴
export type PageDataTemplateComponents = export type PageDataTemplateComponents =
| PageDataTemplateComponent // 단일 컴포넌트 패턴 | PageDataTemplateComponent // 단일 컴포넌트 패턴
| { | {

View File

@@ -44,7 +44,9 @@ const csrDownloadFile = (fileUrl: string, fileName?: string) => {
*/ */
const csrGetMarketingCode = () => { const csrGetMarketingCode = () => {
const route = useRoute() const route = useRoute()
const mcode = Number(`${route.query.mcode != null && route.query.mcode !== '' ? route.query.mcode : ''}`) const mcode = Number(
`${route.query.mcode != null && route.query.mcode !== '' ? route.query.mcode : ''}`
)
return isNaN(mcode) ? undefined : mcode return isNaN(mcode) ? undefined : mcode
} }
@@ -82,15 +84,20 @@ const isNumeric = (str: string): boolean => {
* @param {Function} tm - i18n의 tm 함수 (예: (key) => ({ txt: string })) * @param {Function} tm - i18n의 tm 함수 (예: (key) => ({ txt: string }))
* @param {any} query - 추가 쿼리 파라미터 * @param {any} query - 추가 쿼리 파라미터
*/ */
const getParsedCustomLink = (link: string, { tm, query = {} }: ParsedCustomLinkOptions) => { const getParsedCustomLink = (
const config = useRuntimeConfig() link: string,
{ tm, query = {} }: ParsedCustomLinkOptions
) => {
const runtimeConfig = useRuntimeConfig()
let result = `${link || ''}` let result = `${link || ''}`
// @c{key} 패턴 치환 (예: @c{stoveCommunityUrl}) // @c{key} 패턴 치환 (예: @c{stoveCommunityUrl})
if (link.includes('@c')) { if (link.includes('@c')) {
result = result.replace(/@c\{(.*?)\}/g, (_, key) => { result = result.replace(/@c\{(.*?)\}/g, (_, key) => {
// config.public에서 해당 key 값을 찾아 치환 // runtimeConfig.public에서 해당 key 값을 찾아 치환
return typeof config.public[key] === 'string' ? config.public[key] : '' return typeof runtimeConfig.public[key] === 'string'
? runtimeConfig.public[key]
: ''
}) })
} }
@@ -132,7 +139,7 @@ const setCookieForDay = (name: string, value: string, exp?: number) => {
const setCookie = useCookie(name, { const setCookie = useCookie(name, {
expires: new Date(date), expires: new Date(date),
path: '/' path: '/',
}) })
setCookie.value = value setCookie.value = value
@@ -140,7 +147,9 @@ const setCookieForDay = (name: string, value: string, exp?: number) => {
// 정적 파일인지 확인하는 함수 // 정적 파일인지 확인하는 함수
const isStaticFile = (path: string): boolean => { const isStaticFile = (path: string): boolean => {
return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|scss)$/i.test(path) return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|scss)$/i.test(
path
)
} }
/** /**
@@ -164,5 +173,5 @@ export {
getParsedCustomLink, getParsedCustomLink,
setCookieForDay, setCookieForDay,
isStaticFile, isStaticFile,
isInRange isInRange,
} }

View File

@@ -82,7 +82,7 @@ export const hasComponentGroup = (
* @param components props.components * @param components props.components
* @param componentName 컴포넌트 이름 * @param componentName 컴포넌트 이름
* @param options 옵션 * @param options 옵션
* - hasGroup: groups 속성에서 데이터 가져오기 (기본값: false) * - isGroup: groups 속성에서 데이터 가져오기 (기본값: false)
* - maxLength: 최대 길이 제한 * - maxLength: 최대 길이 제한
* - minLength: 최소 길이 보장 (데이터 반복) * - minLength: 최소 길이 보장 (데이터 반복)
* @returns 컴포넌트 컨테이너 배열 * @returns 컴포넌트 컨테이너 배열
@@ -90,17 +90,17 @@ export const hasComponentGroup = (
export const getComponentContainer = ( export const getComponentContainer = (
components: PageDataTemplateComponents | OperateComponents, components: PageDataTemplateComponents | OperateComponents,
componentName: string, componentName: string,
options: { hasGroup?: boolean; maxLength?: number; minLength?: number } = {} options: { isGroup?: boolean; maxLength?: number; minLength?: number } = {}
) => { ) => {
if (!components) return [] if (!components) return []
const { hasGroup = false, maxLength, minLength } = options const { isGroup = false, maxLength, minLength } = options
// 1. 초기 컨테이너 가져오기 // 1. 초기 컨테이너 가져오기
const component = components[componentName] const component = components[componentName]
if (!component) return [] if (!component) return []
let result = hasGroup && 'groups' in component ? component.groups : component let result = isGroup && 'groups' in component ? component.groups : component
// 2. 최소 길이 보장 (데이터 복제) // 2. 최소 길이 보장 (데이터 복제)
if (minLength && result.length > 1 && result.length < minLength) { if (minLength && result.length > 1 && result.length < minLength) {

View File

@@ -24,76 +24,6 @@ export const csrFormatJWT = (base64EncodeVal: string) => {
return decodeVal return decodeVal
} }
/**
* 타임스탬프를 다양한 날짜 형식으로 변환합니다.
* @param timestamp 타임스탬프 (밀리초 또는 초)
* @param format 날짜 형식 ('YYYY-MM-DD', 'YYYY-MM-DD HH:mm:ss', 'MM/DD/YYYY', 'YYYY년 MM월 DD일' 등)
* @param locale 로케일 (기본값: 'ko-KR')
* @returns 포맷된 날짜 문자열
*/
export const formatTimestamp = (
timestamp: number | string,
format: string = 'YYYY.MM.DD',
_locale: string = 'ko-KR'
): string => {
if (!timestamp) return ''
// 타임스탬프를 숫자로 변환
let ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
// 초 단위인 경우 밀리초로 변환
if (ts < 10000000000) {
ts = ts * 1000
}
const date = new Date(ts)
// 유효하지 않은 날짜인 경우 빈 문자열 반환
if (isNaN(date.getTime())) {
return ''
}
// 미리 정의된 형식들
const predefinedFormats: Record<string, string> = {
'YYYY.MM.DD': date.toISOString().split('T')[0].replace(/-/g, '.'),
'YYYY-MM-DD': date.toISOString().split('T')[0],
'YYYY-MM-DD HH:mm': date.toISOString().replace('T', ' ').substring(0, 16),
'YYYY-MM-DD HH:mm:ss': date.toISOString().replace('T', ' ').split('.')[0],
'MM/DD/YYYY': date.toLocaleDateString('en-US'),
'DD/MM/YYYY': date.toLocaleDateString('en-GB'),
'YYYY년 MM월 DD일': date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
'MM월 DD일': date.toLocaleDateString('ko-KR', {
month: 'long',
day: 'numeric',
}),
}
// 미리 정의된 형식이 있으면 사용
if (predefinedFormats[format]) {
return predefinedFormats[format]
}
// 커스텀 형식 처리
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/** /**
* 배열 또는 객체를 배열로 변환합니다. * 배열 또는 객체를 배열로 변환합니다.
* @param value 변환할 값 (배열, 객체, 또는 undefined/null) * @param value 변환할 값 (배열, 객체, 또는 undefined/null)

View File

@@ -4,7 +4,7 @@ import { DEFAULT_LOCALE_CODE } from '../../i18n.config'
export const getPreferredLanguage = (acceptLanguageHeader = '') => { export const getPreferredLanguage = (acceptLanguageHeader = '') => {
const languages = acceptLanguageHeader const languages = acceptLanguageHeader
.split(',') .split(',')
.map((lang) => { .map(lang => {
const [code, priority = 'q=1'] = lang.trim().split(';q=') const [code, priority = 'q=1'] = lang.trim().split(';q=')
return { code, priority: parseFloat(priority) } return { code, priority: parseFloat(priority) }
}) })
@@ -17,7 +17,7 @@ export const getPreferredLanguage = (acceptLanguageHeader = '') => {
const parseCookies = (cookieHeader: string) => { const parseCookies = (cookieHeader: string) => {
const cookies: Record<string, string> = {} const cookies: Record<string, string> = {}
if (cookieHeader) { if (cookieHeader) {
cookieHeader.split(';').forEach((cookie) => { cookieHeader.split(';').forEach(cookie => {
const [name, value] = cookie.trim().split('=') const [name, value] = cookie.trim().split('=')
if (name && value) { if (name && value) {
cookies[name] = decodeURIComponent(value) cookies[name] = decodeURIComponent(value)
@@ -33,13 +33,17 @@ const parseCookies = (cookieHeader: string) => {
* @param {string} path - 현재 URL 경로 * @param {string} path - 현재 URL 경로
*/ */
export const csrGetFinalLocale = (path = '', coveragesLocales: string[]) => { export const csrGetFinalLocale = (path = '', coveragesLocales: string[]) => {
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const baseDomain = `${config.public.baseDomain}` const baseDomain = `${runtimeConfig.public.baseDomain}`
let finalLocale = DEFAULT_LOCALE_CODE // 기본값 설정 let finalLocale = DEFAULT_LOCALE_CODE // 기본값 설정
// coveragesLocales가 빈 배열이거나 유효하지 않은 경우 기본 언어 반환 // coveragesLocales가 빈 배열이거나 유효하지 않은 경우 기본 언어 반환
if (!coveragesLocales || !Array.isArray(coveragesLocales) || coveragesLocales.length === 0) { if (
!coveragesLocales ||
!Array.isArray(coveragesLocales) ||
coveragesLocales.length === 0
) {
return finalLocale return finalLocale
} }
@@ -60,15 +64,21 @@ export const csrGetFinalLocale = (path = '', coveragesLocales: string[]) => {
} }
// 2. LOCALE 쿠키 언어 // 2. LOCALE 쿠키 언어
const cookieLanguage = `${useCookie('LOCALE', { domain: baseDomain }).value}`.toLowerCase() const cookieLanguage =
`${useCookie('LOCALE', { domain: baseDomain }).value}`.toLowerCase()
if (cookieLanguage && cookieLanguage !== '') { if (cookieLanguage && cookieLanguage !== '') {
finalLocale = cookieLanguage finalLocale = cookieLanguage
return finalLocale return finalLocale
} }
// 3. 브라우저 언어 // 3. 브라우저 언어
const browserLanguage = `${navigator.language || navigator.languages[0]}`.toLowerCase() const browserLanguage =
if (browserLanguage && browserLanguage !== '' && coveragesLocales.includes(browserLanguage)) { `${navigator.language || navigator.languages[0]}`.toLowerCase()
if (
browserLanguage &&
browserLanguage !== '' &&
coveragesLocales.includes(browserLanguage)
) {
finalLocale = browserLanguage finalLocale = browserLanguage
return finalLocale return finalLocale
} }
@@ -89,7 +99,11 @@ export const ssrGetFinalLocale = (path = '', headers: any, coveragesLocales: str
let finalLocale // 기본값 설정 let finalLocale // 기본값 설정
try { try {
// coveragesLocales가 빈 배열이거나 유효하지 않은 경우 기본 언어 반환 // coveragesLocales가 빈 배열이거나 유효하지 않은 경우 기본 언어 반환
if (!coveragesLocales || !Array.isArray(coveragesLocales) || coveragesLocales.length === 0) { if (
!coveragesLocales ||
!Array.isArray(coveragesLocales) ||
coveragesLocales.length === 0
) {
return finalLocale return finalLocale
} }
@@ -101,7 +115,11 @@ export const ssrGetFinalLocale = (path = '', headers: any, coveragesLocales: str
} }
const pathLocalee = `${path.split('/')[1]}`.toLowerCase() const pathLocalee = `${path.split('/')[1]}`.toLowerCase()
// URL path에 포함된 언어 정보가 지원하는 언어인지 체크 // URL path에 포함된 언어 정보가 지원하는 언어인지 체크
if (pathLocalee && pathLocalee !== '' && coveragesLocales.includes(pathLocalee)) { if (
pathLocalee &&
pathLocalee !== '' &&
coveragesLocales.includes(pathLocalee)
) {
finalLocale = pathLocalee finalLocale = pathLocalee
return finalLocale return finalLocale
} }
@@ -110,8 +128,14 @@ export const ssrGetFinalLocale = (path = '', headers: any, coveragesLocales: str
// 2. LOCALE 쿠키 언어 (SSR에서는 headers에서 직접 파싱) // 2. LOCALE 쿠키 언어 (SSR에서는 headers에서 직접 파싱)
const cookieHeader = headers.cookie || '' const cookieHeader = headers.cookie || ''
const cookies = parseCookies(cookieHeader) const cookies = parseCookies(cookieHeader)
const cookieLanguage = cookies.LOCALE ? `${cookies.LOCALE}`.toLowerCase() : '' const cookieLanguage = cookies.LOCALE
if (cookieLanguage && cookieLanguage !== '' && coveragesLocales.includes(cookieLanguage)) { ? `${cookies.LOCALE}`.toLowerCase()
: ''
if (
cookieLanguage &&
cookieLanguage !== '' &&
coveragesLocales.includes(cookieLanguage)
) {
finalLocale = cookieLanguage finalLocale = cookieLanguage
return finalLocale return finalLocale
} }

View File

@@ -9,10 +9,10 @@ import { csrFormatJWT } from '#layers/utils/formatUtil'
* Stove 로그인 * Stove 로그인
*/ */
export const csrGoStoveLogin = () => { export const csrGoStoveLogin = () => {
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const loginUrl = config.public.stoveLoginUrl const loginUrl = runtimeConfig.public.stoveLoginUrl
const stoveGameId = gameDataStore.gameData?.game_id const stoveGameId = gameDataStore.gameData?.game_id
const stoveGameNo = gameDataStore.gameData?.game_code const stoveGameNo = gameDataStore.gameData?.game_code
const redirectUrl = encodeURIComponent(location.href) const redirectUrl = encodeURIComponent(location.href)

View File

@@ -23,17 +23,15 @@ export const getImageHost = (
if (/^(https?:\/\/|www\.)/.test(path)) return path if (/^(https?:\/\/|www\.)/.test(path)) return path
const config = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const { staticUrl, assetsUrl } = config.public const { staticUrl, assetsUrl } = runtimeConfig.public
const { imageType = 'game' } = options const { imageType = 'game' } = options
const isDevelopment = process.env.NODE_ENV === 'development' const isDevelopment = import.meta.dev
const isTypeGame = imageType === 'game' const isTypeGame = imageType === 'game'
// * [TODO] 수정 필요 : 게임별 이미지 로컬에 추가 필요. // * [TODO] 수정 필요 : 게임별 이미지 로컬에 추가 필요.
if (isTypeGame) { if (isTypeGame) return `${staticUrl}${path}`
return `${staticUrl}${path}`
}
// 개발 환경일 때는 루트 경로 생략 // 개발 환경일 때는 루트 경로 생략
if (isDevelopment) return path if (isDevelopment) return path
@@ -54,12 +52,13 @@ export const getDeviceSrc = (
pathArray: PageDataResourceGroupResPath, pathArray: PageDataResourceGroupResPath,
options?: { options?: {
resourcesType?: 'image' | 'video' resourcesType?: 'image' | 'video'
imageType?: 'common' | 'game'
} }
) => { ) => {
// pathArray가 없으면 null 반환 // pathArray가 없으면 null 반환
if (!pathArray) return null if (!pathArray) return null
const { resourcesType = 'image' } = options ?? {} const { resourcesType = 'image', imageType = 'game' } = options ?? {}
const pcField = resourcesType === 'video' ? 'path_vid_pc' : 'path_pc' const pcField = resourcesType === 'video' ? 'path_vid_pc' : 'path_pc'
const mobileField = resourcesType === 'video' ? 'path_vid_mo' : 'path_mo' const mobileField = resourcesType === 'video' ? 'path_vid_mo' : 'path_mo'
@@ -70,8 +69,8 @@ export const getDeviceSrc = (
if (!pcPath && !mobilePath) return null if (!pcPath && !mobilePath) return null
const resolvedImages = { const resolvedImages = {
pc: pcPath ? getImageHost(pcPath) : '', pc: pcPath ? getImageHost(pcPath, { imageType }) : '',
mobile: mobilePath ? getImageHost(mobilePath) : '', mobile: mobilePath ? getImageHost(mobilePath, { imageType }) : '',
} }
return { return {

View File

@@ -88,6 +88,8 @@ export default defineNuxtConfig({
stoveGnb: process.env.STOVE_GNB, stoveGnb: process.env.STOVE_GNB,
stoveCs: process.env.STOVE_CS,
stoveLauncherScript: process.env.STOVE_LAUNCHER_SCRIPT, stoveLauncherScript: process.env.STOVE_LAUNCHER_SCRIPT,
stoveClientDownloadUrl: process.env.STOVE_CLIENT_DOWNLOAD_URL, stoveClientDownloadUrl: process.env.STOVE_CLIENT_DOWNLOAD_URL,
stoveLoginUrl: process.env.STOVE_LOGIN_URL, stoveLoginUrl: process.env.STOVE_LOGIN_URL,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Some files were not shown because too many files have changed in this diff Show More