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_LOGIN_URL=https://accounts-dev.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ######################################################################
# 81plug
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_LOGIN_URL=https://accounts.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ######################################################################
# 81plug
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_LOGIN_URL=https://accounts-qa.onstove.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ######################################################################
# 81plug
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_LOGIN_URL=https://accounts.gate8.com/login
# STOVE
STOVE_CS=https://cs.onstove.com/service
# Log Tracking ######################################################################
# 81plug
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 meta = data.meta_tag_json ?? ({} as GameDataMetaTag)
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 = [

View File

@@ -6,22 +6,34 @@
<clientOnly>
<!-- 로고 -->
<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 class="inspection-content">
<!-- 점검 메시지 -->
<h1 class="inspection-title text-lg md:text-2xl">
{{ tm('Inspection_Now_Maintenance') }}
</h1>
<div class="inspection-cards">
<!-- 점검 시간 카드 -->
<div 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
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 v-dompurify-html="getLocaleTimezone('', '')" class="time-row"></div>
<div
v-dompurify-html="getLocaleTimezone('', '')"
class="time-row"
></div>
</div>
</div>
@@ -34,7 +46,7 @@
<span v-else v-dompurify-html="tm('org_Inspection_During_Maintenance')"></span>
</h3>
<div class="button-group justify-center">
<!-- <a
<!-- <a
:href="communityUrl"
target="_blank"
rel="noopener noreferrer"
@@ -45,49 +57,63 @@
<AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" />
</a> -->
<AtomsButtonVariant
type="custom"
class="inspection-btn inspection-btn-community color-black text-sm md:text-base"
:class="{ 'inspection-btn-outline flex-1' : launchingStatus }"
@click="handleCommunityClick"
<AtomsButtonVariant
type="custom"
class="inspection-btn inspection-btn-community color-black text-sm md:text-base"
:class="{ 'inspection-btn-outline flex-1': launchingStatus }"
@click="handleCommunityClick"
>
<span>{{ tm('Inspection_Community_Btn') }}</span>
<AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" />
</AtomsButtonVariant>
<AtomsButtonVariant
v-if="launchingStatus"
type="custom"
platform="pc"
class="inspection-btn inspection-btn-primary w-auto color-black text-sm md:text-base flex-1"
@click="handleGameStart"
>
<span>게임 시작</span>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<span>{{ tm('Inspection_Community_Btn') }}</span>
<AtomsIconsLongArrowRightLine :size="16" color="#1F1F1F" />
</AtomsButtonVariant>
<AtomsButtonVariant
v-if="launchingStatus"
type="custom"
platform="pc"
class="inspection-btn inspection-btn-primary w-auto color-black text-sm md:text-base flex-1"
@click="handleGameStart"
>
<span>게임 시작</span>
<svg 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>
</AtomsButtonVariant>
<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>
</AtomsButtonVariant>
</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">
{{ tm('Inspection_Txt_Download') || '게임 다운로드' }}
</h3>
<div class="flex flex-row gap-3">
<AtomsButtonLauncher
v-for="(btn, index) in enabledMarkets" :key="index"
:class="getButtonClass(btn.platform)"
<AtomsButtonLauncher
v-for="(btn, index) in enabledMarkets"
:key="index"
:class="getButtonClass(btn.platform)"
class="h-[48px]"
:platform="btn.platform as any"
:url="btn.url"
:type="btn.platform === 'pc' ? 'default' : 'single'"
:platform="btn.platform as any"
:url="btn.url"
:type="btn.platform === 'pc' ? 'default' : 'single'"
variant="outlined"
>
{{ getButtonText(btn.platform) }}
{{ getButtonText(btn.platform) }}
</AtomsButtonLauncher>
</div>
</div>
@@ -96,11 +122,10 @@
</div>
</clientOnly>
</section>
</template>
<script setup lang="ts">
import { globalDateFormat } from '@seed-next/date';
import { globalDateFormat } from '@seed-next/date'
import { useCheckGameStart } from '#layers/composables/useGameStart'
const config = useRuntimeConfig()
@@ -157,7 +182,6 @@ const launchingStatus = computed(() => {
return webInspectionData.value?.launching_status
})
// const market_json = {
// pc: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 },
// app_store: { url: 'https://apps.apple.com/app/id1234567890', use_yn: 0 },
@@ -166,21 +190,25 @@ const launchingStatus = computed(() => {
//gameData.value.market_json 값 중 use_yn === 1 인 항목만 배열로 변환
const enabledMarkets = computed(() => {
return Object.entries(gameData.value.market_json)
// return Object.entries(market_json)
.filter(([, info]: [string, any]) => info && info.use_yn === 1)
.map(([platform, info]: [string, any]) => ({ platform, url: info.url as string }))
return (
Object.entries(gameData.value.market_json)
// return Object.entries(market_json)
.filter(([, info]: [string, any]) => info && info.use_yn === 1)
.map(([platform, info]: [string, any]) => ({
platform,
url: info.url as string,
}))
)
})
const logoImgUrl = computed(() => {
const currentLocale = locale.value || 'ko'
const currentLocale = locale.value || 'ko'
const localeData = (webInspectionData.value as any)?.[currentLocale]
return getImageHost(localeData.img_json.bi_large)
})
const communityUrl = computed(() => {
const currentLocale = locale.value || 'ko'
const currentLocale = locale.value || 'ko'
const localeData = (webInspectionData.value as any)?.[currentLocale]
return localeData?.url_json?.community
})
@@ -192,7 +220,7 @@ const handleCommunityClick = () => {
const getButtonClass = (platform: string) => {
// pc가 있으면 pc만 flex-1, 나머지는 기본
const hasPc = enabledMarkets.value.some(btn => btn.platform === 'pc')
if(hasPc) {
if (hasPc) {
return platform === 'pc' ? `flex-1 btn-platform-pc` : ``
}
//pc가 없으면서 하나만 있으면 ''
@@ -215,7 +243,7 @@ const getButtonText = (platform: string) => {
if (platform === 'app_store') {
return tm('platform_app_store')
}
return ''
}
@@ -228,15 +256,14 @@ const handleGameStart = () => {
definePageMeta({
middleware: ['inspection'],
layout: 'only-stove',
showLoading: false
showLoading: false,
})
</script>
<style scoped>
.inspection-section {
@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 {
@@ -265,7 +292,6 @@ definePageMeta({
.inspection-time-card {
@apply flex flex-col items-center gap-4;
}
.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;
cursor: pointer;
transition: all 0.2s;
}
.inspection-btn span {

View File

@@ -16,4 +16,16 @@
border: none;
outline: none;
}
/* Remove number input spinner */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
}

View File

@@ -3,19 +3,19 @@
.size-large {
/* height: 64px */
@apply px-10 h-16 text-lg rounded-lg
before:rounded after:rounded;
before:rounded-lg after:rounded-lg;
}
.size-medium {
/* height: 56px */
@apply px-10 h-14 text-base rounded-lg
before:rounded after:rounded;
before:rounded-lg after:rounded-lg;
}
.size-small {
/* height: 48px */
@apply px-10 h-12 text-sm rounded-lg
before:rounded after:rounded;
before:rounded-lg after:rounded-lg;
}
.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];
}
.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 {
@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 */
@layer components {
.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 {

View File

@@ -22,7 +22,8 @@
@apply hidden md:block;
}
.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
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 { gameData } = useGameDataStore()
const gameDataStore = useGameDataStore()
const { isProcessing, validateLauncher } = useCheckGameStart()
const { gameData } = storeToRefs(gameDataStore)
const PLATFORM_ICON_MAP: Record<Platform, string> = {
google_play: 'AtomsIconsLogoGoogle',
app_store: 'AtomsIconsLogoApple',
@@ -40,7 +42,7 @@ const DUP_IMAGE_MAP: Record<Platform, string> = {
} as const
const componentTag = computed(() => {
if (props.platform === 'stove') {
if (props.type !== 'duplication' && props.platform === 'stove') {
return 'a'
}
return 'button'
@@ -75,7 +77,7 @@ const handleClick = () => {
return
}
const url = gameData?.market_json[props.platform]?.url
const url = gameData.value?.market_json[props.platform]?.url
if (url) window.open(url, '_blank')
}
</script>

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,10 @@ withDefaults(defineProps<Props>(), {
:width="size"
:height="size"
viewBox="0 0 12 12"
:fill="color"
>
<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"
:fill="color"
/>
</svg>
</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"
:height="size"
viewBox="0 0 20 20"
:fill="color"
>
<path
fill-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"
:fill="color"
/>
</svg>
</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"
:height="size"
viewBox="0 0 32 32"
fill="none"
:fill="color"
>
<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"
:fill="color"
/>
</svg>
</template>

View File

@@ -1,95 +1,146 @@
<template>
<div class="select-language" :class="{ 'language-changing': isChanging }">
<button
: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="{ 'opacity-50 cursor-not-allowed': isChanging }"
@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)">
<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>
<defs>
<clipPath id="clip0_5964_1685">
<rect width="16" height="16" fill="#CCCCCC"/>
</clipPath>
<clipPath id="clip0_5964_1685">
<rect width="16" height="16" fill="#CCCCCC" />
</clipPath>
</defs>
</svg>
<span class="flex-1 text-sm text-left transition-all duration-300">
{{ isChanging ? '언어 변경 중...' : getLanguageName(selectedLocale) }}
</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">
<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"/>
<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"
>
<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>
</svg>
<!-- 드롭다운 화살표 -->
<svg
v-else
class="w-3 h-3 text-gray-300 transition-transform duration-200"
<svg
v-else
class="w-3 h-3 text-gray-300 transition-transform duration-200"
:class="{ 'rotate-180': isDropdownOpen }"
viewBox="0 0 12 12" 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>
</button>
<div v-if="isDropdownOpen" class="dropdown-menu">
<div v-for="localeItem in availableLanguages" :key="localeItem.code" class="dropdown-menu-item">
<button
class="dropdown-menu-item-button"
:class="{ 'current': localeItem.code === selectedLocale }"
@click="selectLanguage(localeItem.code)"
viewBox="0 0 12 12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<svg
width="15"
height="11"
viewBox="0 0 15 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="transition-opacity duration-200"
<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>
</button>
<div v-if="isDropdownOpen" class="dropdown-menu">
<div
v-for="localeItem in availableLanguages"
:key="localeItem.code"
class="dropdown-menu-item"
>
<button
class="dropdown-menu-item-button"
:class="{ current: localeItem.code === selectedLocale }"
@click="selectLanguage(localeItem.code)"
>
<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>
<span class="text-sm">{{ localeItem.name }}</span>
</button>
<svg
width="15"
height="11"
viewBox="0 0 15 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
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"
/>
</svg>
<span class="text-sm">{{ localeItem.name }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const baseDomain = `${config.public.baseDomain}`
const runtimeConfig = useRuntimeConfig()
const baseDomain = `${runtimeConfig.public.baseDomain}`
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
// 사용 가능한 언어 목록
const availableLanguages = computed(() => {
return gameData.value?.lang_codes?.map(localeCode => ({
code: localeCode,
name: getLanguageName(localeCode)
})) || [{ code: 'ko', name: '한국어' }]
return (
gameData.value?.lang_codes?.map(localeCode => ({
code: localeCode,
name: getLanguageName(localeCode),
})) || [{ code: 'ko', name: '한국어' }]
)
})
// 언어 코드를 한국어 이름으로 변환하는 함수
const getLanguageName = (localeCode: string) => {
const languageNames: Record<string, string> = {
'ko': '한국어',
'en': 'English',
'ja': '日本語',
ko: '한국어',
en: 'English',
ja: '日本語',
'zh-cn': '简体中文',
'zh-tw': '繁體中文',
'es': 'Español',
'fr': 'Français',
'de': 'Deutsch',
'pt': 'Português',
'th': 'ไทย',
'it': 'Italiano'
es: 'Español',
fr: 'Français',
de: 'Deutsch',
pt: 'Português',
th: 'ไทย',
it: 'Italiano',
}
return languageNames[localeCode] || localeCode
}
@@ -113,7 +164,7 @@ const selectLanguage = async (localeCode: string) => {
isDropdownOpen.value = false
return
}
selectedLocale.value = localeCode as any
isDropdownOpen.value = false
await switchLanguage()
@@ -121,38 +172,37 @@ const selectLanguage = async (localeCode: string) => {
// 언어 변경 함수 (서버 미드웨어를 통한 gameData 갱신)
const switchLanguage = async () => {
if (!selectedLocale.value || isChanging.value) return
isChanging.value = true
try {
// URL 경로를 통해 언어 변경
const path = switchLocalePath(selectedLocale.value as any)
if (path) {
// 언어 쿠키 설정 (클라이언트 사이드) - 페이지 이동 전에 설정
const localeCookie = useCookie('LOCALE', {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
sameSite: 'lax'
})
localeCookie.value = selectedLocale.value.toUpperCase()
// 페이지 데이터 초기화 (새로운 언어로 다시 로드되도록)
pageDataStore.clearPageData()
window.location.href = path
// URL 경로를 통해 언어 변경
const path = switchLocalePath(selectedLocale.value as any)
if (path) {
// 언어 쿠키 설정 (클라이언트 사이드) - 페이지 이동 전에 설정
const localeCookie = useCookie('LOCALE', {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
sameSite: 'lax',
})
localeCookie.value = selectedLocale.value.toUpperCase()
// 페이지 데이터 초기화 (새로운 언어로 다시 로드되도록)
pageDataStore.clearPageData()
window.location.href = path
// 언어 변경 및 라우팅
// await setLocale(selectedLocale.value as any)
// 전체 페이지에 페이드 아웃 효과 적용
// document.body.style.transition = 'opacity 0.1s ease-out'
// document.body.style.opacity = '0'
// // 페이드 아웃 완료 후 페이지 이동
// await new Promise(resolve => setTimeout(resolve, 100))
// 서버 미드웨어를 통해 gameData 갱신을 위해 페이지 새로고침
// 이렇게 하면 서버 미드웨어가 새로운 언어로 gameData를 다시 가져옴
}
} catch {
// 오류 발생 시 이전 언어로 복원
@@ -248,5 +298,4 @@ body {
.dropdown-menu-item-button.current svg {
opacity: 1;
}
</style>
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,7 @@ onUnmounted(() => {
<Transition name="fade">
<div
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"
@click="handleOutsideClick"
>

View File

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

View File

@@ -62,6 +62,20 @@ const thumbOptions = computed<Options>(() => ({
prev: 'arrow-prev',
next: 'arrow-next',
},
breakpoints: {
[BREAKPOINTS.md - 1]: {
padding: {
left: 40,
right: 40,
},
},
[BREAKPOINTS.sm - 1]: {
padding: {
left: 20,
right: 20,
},
},
},
}))
const getThumbnailSrc = (item: PageDataTemplateComponentSet) => {
@@ -158,12 +172,12 @@ onBeforeUnmount(() => {
@apply md:w-[calc(100%-16px)];
}
.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];
}
.thumbnail-slide:hover,
.thumbnail-slide.is-active {
@apply bg-[var(--pagination-active)] md:bg-transparent;
@apply bg-[var(--pagination-active)];
}
.thumbnail-slide::after {
@apply border-[var(--pagination-disabled)];
@@ -194,10 +208,10 @@ onBeforeUnmount(() => {
}
.thumbnail-carousel.thumbnail-default .thumbnail-slide:hover 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 {
@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;
}
.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];
}
.thumbnail-carousel.thumbnail-media .thumbnail-splide:deep(.splide__track) {
@apply !px-[20px] sm:!px-[40px] md:!px-[0];
@apply max-w-[calc(100%+40px)] mt-[20px] mx-[-20px]
sm:max-w-[calc(100%+80px)] sm:mx-[-40px]
md:max-w-[100%] md:mt-[28px] md:mx-auto md:px-[64px];
}
.thumbnail-carousel.thumbnail-media:deep(.arrow-prev) {
@apply left-[48px];
@apply left-[0];
}
.thumbnail-carousel.thumbnail-media:deep(.arrow-next) {
@apply right-[48px];
@apply right-[0];
}
.thumbnail-carousel.thumbnail-media .thumbnail-slide {
@apply aspect-[16/9] w-[92px] md:w-[128px];

View File

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

View File

@@ -19,7 +19,7 @@ const mainRef = ref<HTMLElement>()
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 => {
@@ -31,7 +31,7 @@ const isTemplateVisible = (template: PageDataTemplate): boolean => {
// 템플릿 목록 계산
const visibleTemplates = computed(() =>
Object.values(props.pageData.templates).filter(isTemplateVisible)
Object.values(props.pageData?.templates).filter(isTemplateVisible)
)
// SEO 메타 태그 설정

View File

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

View File

@@ -23,8 +23,5 @@ const handleVideoPlayClick = () => {
</script>
<template>
<AtomsButtonPlay
:resources-data="resourcesData"
@click="handleVideoPlayClick"
/>
<AtomsButtonPlay @click="handleVideoPlayClick" />
</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,
options?: {
resourcesType?: 'image' | 'video'
imageType?: 'game' | 'common'
}
) => {
const result = getDeviceSrc(path, options)

View File

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

View File

@@ -4,14 +4,18 @@ import { csrGoStoveLogin } from '#layers/utils/stoveUtil'
export const useCheckGameStart = () => {
const { tm } = useI18n()
const modalStore = useModalStore()
const runtimeConfig = useRuntimeConfig()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const { handleTokenValidation } = useTokenValidation()
const { gameData } = storeToRefs(gameDataStore)
const stoveCs = runtimeConfig.public.stoveCs
const isProcessing = ref(false) // 연속 클릭 방지
const isShowCheckLauncher = ref(false) // 런처 실행 로딩 표시
const isShowDownloadLauncher = ref(false) // 런처 다운로드 표시
const customerService = 'https://www.google.com' //[TODO] 고객센터 링크
const customerServiceUrl = `${stoveCs}/${gameData.value?.game_id}`
// 에러 처리
const errorHandler = (errorCode: number) => {
@@ -48,9 +52,9 @@ export const useCheckGameStart = () => {
// 일시적으로 오류가 발생했습니다. 잠시 후 다시 이용해 주세요. 동일한 현상이 계속 발생할 경우 고객센터로 문의해 주세요.
modalStore.handleOpenConfirm({
contentText: tm('Alert_Error'),
confirmButtonText: tm('Text_StoveLogin'),
confirmButtonText: tm('Text_Customer'),
confirmButtonEvent: () => {
window.open(customerService, '_blank')
window.open(customerServiceUrl, '_blank')
},
})
break
@@ -87,7 +91,7 @@ export const useCheckGameStart = () => {
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
const accessTokenSub = useCookie('SUAT')
const accessTokenSub = csrGetAccessToken()
const stoveGameId = gameData.value?.game_id || ''
const nationCookie = useCookie('NNTO').value
const localeCookie = useCookie('LOCALE').value
@@ -96,7 +100,7 @@ export const useCheckGameStart = () => {
// 토큰 유효성 체크
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) => {
console.log('🚀 ~ getGameDataExternal ~ req:', req)
// const config = useRuntimeConfig()
const config = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/game?game_domain=${req.gameDomain}&lang_code=${req.langCode}`
try {
@@ -23,7 +22,7 @@ export const useGetGameDataExternal = () => {
console.log('🚀 ~ getGameDataExternal ~ response:', response)
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
/* if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
response.value = {
inspection_status: 1,
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 logPrefix = {
exception: '[Exception] /composables/useGetGameMaintenance',
failure: '[Failure] /composables/useGetGameMaintenance'
failure: '[Failure] /composables/useGetGameMaintenance',
}
const isGameMaintenance = ref(false) // 게임 서버 점검 여부
@@ -20,7 +23,11 @@ const useGetGameMaintenance = () => {
const setGameMaintenanceFalse = () => {
setIsGameMaintenance(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) {
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* const config = useRuntimeConfig()
if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
/* const runtimeConfig = useRuntimeConfig()
if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
res.value = {
total_count: 1,
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개 이상 점검일 경우 점검 중으로 간주
inspectionStore.setGameMaintenanceData({
ts_start_date: res.value?.list[0].start_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)
} 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 logPrefix = {
exception: '[Exception] /composables/useGetInspectionDataExternal',
failure: '[Failure] /composables/useGetInspectionDataExternal'
failure: '[Failure] /composables/useGetInspectionDataExternal',
}
const webInspectionData = ref<WebInspectionData | null>(null)
const isWebInspection = ref(false) // 웹 점검 여부
@@ -24,15 +28,18 @@ export const useGetInspectionDataExternal = () => {
* @description https://wiki.smilegate.net/pages/viewpage.action?pageId=563198067
*/
const getInspectionDataExternal = async (req: ReqGetInspectionData) => {
// const config = useRuntimeConfig()
// const runtimeConfig = useRuntimeConfig()
const apiUrl = `${req.baseApiUrl}/pub-comm/v3.0/inspection/${req.gameId}`
try {
const response = (await commonFetch('GET', apiUrl)) as ResGetInspectionData
console.log("🚀 ~ getInspectionDataExternal ~ response:", response)
const response = (await commonFetch(
'GET',
apiUrl
)) as ResGetInspectionData
console.log('🚀 ~ getInspectionDataExternal ~ response:', response)
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
/* if (['local', 'local-gate8', 'dev'].includes(`${runtimeConfig.public.runType}`)) {
response.value = {
inspection_status: 1,
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> => {
const { pageSeq, pageVer, pageVerTmplSeq, langCode, q, qc } = params
const config = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/operateResources`
const queryParams: Record<string, string | number> = {
@@ -68,8 +68,8 @@ export const useResourcesData = () => {
size,
} = params
const config = useRuntimeConfig()
const stoveApiBaseUrl = config.public.stoveApiUrl
const runtimeConfig = useRuntimeConfig()
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const apiUrl = `${stoveApiBaseUrl}/cwms/v3.0/article_group/${articleGroupCode}/${articleGroupSeq}/article/list`
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 FxDownload01 from '#layers/templates/FxDownload01/index.vue'
import FxSecure01 from '#layers/templates/FxSecure01/index.vue'
import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue'
const templateRegistry = {
GR_VISUAL_01: { component: GrVisual01 },
@@ -28,6 +29,7 @@ const templateRegistry = {
FX_VIDEO_01: { component: FxVideo01 },
FX_DOWNLOAD_01: { component: FxDownload01 },
FX_SECURE_01: { component: FxSecure01 },
FX_PREREGIST_01: { component: FxPreregist01 },
} as const
type TemplateKey = keyof typeof templateRegistry

View File

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

View File

@@ -1,15 +1,7 @@
<script setup lang="ts">
console.log('🚀 ~ promotion')
</script>
<script setup lang="ts"></script>
<template>
<div class="promotion-wrap">
<slot />
</div>
<LayoutsHeader />
<slot />
<LayoutsFooter />
</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
//현재 url에서 게임 도메인만 추출
const currentDomain = window.location.hostname;
const config = useRuntimeConfig()
const currentDomain = window.location.hostname
const runtimeConfig = useRuntimeConfig()
const req: GameDataRequest = {
gameDomain: `${currentDomain}`,
langCode: `${currentLangCode}`,
game_alias: '',
lang_code: `${currentLangCode}`,
baseApiUrl: `${config.public.stoveApiUrl}`,
baseApiUrl: `${runtimeConfig.public.stoveApiUrl}`,
gameId: '',
}
const { getGameDataExternal } = useGetGameDataExternal()
@@ -30,7 +30,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const langCodes = gameData?.lang_codes
// 허용된 언어 코드 목록
const allowedLangCodes = langCodes || []

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* i18n 다국어 로더 플러그인
* S3에서 공통 다국어 파일을 로드하여 i18n 메시지에 주입합니다.
*/
export default defineNuxtPlugin(async (nuxtApp) => {
export default defineNuxtPlugin(async nuxtApp => {
const $i18n = nuxtApp.$i18n as any
const runtimeConfig = useRuntimeConfig()
@@ -31,7 +31,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
'zh-cn': 'zh-CN',
}
langCodes.forEach((langCode) => {
langCodes.forEach(langCode => {
// 로케일 코드 변환 (필요한 경우)
const normalizedLangCode = localeMap[langCode] || langCode
@@ -47,6 +47,9 @@ export default defineNuxtPlugin(async (nuxtApp) => {
}
})
} 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 {
getHeader,
getRequestHost,
defineEventHandler,
} from 'h3'
import { getHeader, getRequestHost, defineEventHandler } from 'h3'
import { ssrGetFinalLocale } from '../../utils/localeUtil'
import type { GameDataResponse } from '../../types/api/gameData'
import type { ResGetInspectionData } from '../../types/InspectionType'
@@ -17,7 +13,9 @@ import { isStaticFile } from '#layers/utils/commonUtil'
* @param customMaxAge - 커스텀 max-age 값 (초 단위)
*/
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',
customMaxAge?: number
): void {
@@ -59,7 +57,7 @@ function setCacheHeaders(
const cache = new LRUCache({
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(), {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365 // 1년 (초 단위)
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
})
}
@@ -142,26 +140,26 @@ export default defineEventHandler(async event => {
// const url = getRequestURL(event)
// if (['local', 'local-gate8', 'dev'].includes(runType)) {
// Sandbox 이상 환경에서만 동작 및 확인 가능 (local, dev는 통과 처리)
// try {
// 언어 코드 추출
// const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
// console.log("🚀 ~ finalLocale:", finalLocale)
// setFinalLocaleCookie(event, finalLocale, baseDomain)
// Sandbox 이상 환경에서만 동작 및 확인 가능 (local, dev는 통과 처리)
// try {
// 언어 코드 추출
// const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
// console.log("🚀 ~ finalLocale:", finalLocale)
// setFinalLocaleCookie(event, finalLocale, baseDomain)
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
// fnLocaleMiddleware(event, finalLocale)
// } catch (e) {
// console.error('[Exception] /server/middleware/middleware-02-global: ', e)
// }
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
// fnLocaleMiddleware(event, finalLocale)
// } catch (e) {
// console.error('[Exception] /server/middleware/middleware-02-global: ', e)
// }
// }
const config = useRuntimeConfig()
const iBaseApiUrl = `${config.public.stoveApiUrlServer}`
const baseDomain = `${config.public.baseDomain}`
const stoveApiUrlBaseServer = config.public.stoveApiUrlServer
const runtimeConfig = useRuntimeConfig()
const iBaseApiUrl = `${runtimeConfig.public.stoveApiUrlServer}`
const baseDomain = `${runtimeConfig.public.baseDomain}`
const stoveApiUrlBaseServer = runtimeConfig.public.stoveApiUrlServer
const apiUrl = `${stoveApiUrlBaseServer}/pub-comm/v1.0/template/game`
let initGameData: GameDataResponse | null = null
@@ -180,7 +178,6 @@ export default defineEventHandler(async event => {
}
try {
const queryParams: Record<string, string> = {
game_domain: cleanHost || '',
lang_code: '',
@@ -193,13 +190,13 @@ export default defineEventHandler(async event => {
initLangCodes = initResponse?.value?.lang_codes || null
initDefaultLocale = initResponse?.value?.default_lang_code || null
console.log("🚀 ~ 000111 initLangCodes:", initLangCodes)
console.log('🚀 ~ 000111 initLangCodes:', initLangCodes)
} catch (error) {
console.error('init gameData load error:', error)
}
const fullPath = event.path
// 1-1. 정적 파일 패스
if (isStaticFile(event.path)) {
return
@@ -208,7 +205,12 @@ export default defineEventHandler(async event => {
// 1-2. /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)
return
}
@@ -246,18 +248,16 @@ export default defineEventHandler(async event => {
})) as GameDataResponse | null
// 언어패스 쿠키 굽기 - 장기방안에서는 굽지않음
if(initLangCodes?.includes(finalLocale)) {
if (initLangCodes?.includes(finalLocale)) {
setFinalLocaleCookie(event, finalLocale, baseDomain)
}
if (response?.code === 0 && 'value' in response) {
event.context.gameData = response.value
event.context.googleAnalyticsId = response.value?.ga_code
// console.log('🚀 ~ gameData:', response.value)
// 점검 데이터 조회
let inspectionData
if (cache.has(cacheKey)) {
@@ -267,39 +267,50 @@ export default defineEventHandler(async event => {
if (response?.value?.game_id) {
const inspectionApiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${response?.value?.game_id}`
// 직접 $fetch 사용 (composable 사용하지 않음)
const inspectionResponse = await $fetch<ResGetInspectionData>(inspectionApiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
const inspectionResponse = await $fetch<ResGetInspectionData>(
inspectionApiUrl,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
})
)
inspectionData = inspectionResponse?.value?.inspection
cache.set(cacheKey, inspectionData) // 캐시에 저장
// console.log("🚀 ~ inspectionData:", inspectionData)
}
}
// 4. 현재 시간과 점검 기간 비교
const currentTime = Date.now()
const tsStartDate = inspectionData?.ts_start_date || 0
const tsEndDate = inspectionData?.ts_end_date || 0
const timeUntilInspectionSeconds = Math.floor(
(tsStartDate - currentTime) / 1000
)
// 4. 현재 시간과 점검 기간 비교
const currentTime = Date.now()
const tsStartDate = inspectionData?.ts_start_date || 0
const tsEndDate = inspectionData?.ts_end_date || 0
const timeUntilInspectionSeconds = Math.floor((tsStartDate - currentTime) / 1000)
// 5. 점검 상태별 캐시 설정
if (
inspectionData?.inspection_status === 1 &&
currentTime >= tsStartDate &&
currentTime <= tsEndDate
) {
/**
* 점검 중인 경우
* - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ
* - 점검 URL 경로가 아닐 경우 no-cache 설정
* - 화이트 리스트 체크
*/
// 현재 경로가 점검 페이지가 아닐 경우 리다이렉트
const inspectionPath = `/${finalLocale}/inspection`
if (fullPath !== inspectionPath) {
setCacheHeaders(event, 'no-cache')
}
// 5. 점검 상태별 캐시 설정
if (inspectionData?.inspection_status === 1 && currentTime >= tsStartDate && currentTime <= tsEndDate) {
/**
* 점검 중인 경우
* - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인ㄹ
* - 점검 URL 경로가 아닐 경우 no-cache 설정
* - 화이트 리스트 체크
*/
// 현재 경로가 점검 페이지가 아닐 경우 리다이렉트
const inspectionPath = `/${finalLocale}/inspection`
if (fullPath !== inspectionPath) {
setCacheHeaders(event, 'no-cache')
}
// 점검 중일 때 IP 필터링 활성화 여부 확인
if (inspectionData?.ip_filter_use_yn === 'Y') {
const clientIP = getTrueClientIp(event.node.req as any)
// 점검 중일 때 IP 필터링 활성화 여부 확인
if (inspectionData?.ip_filter_use_yn === 'Y') {
@@ -328,47 +339,50 @@ export default defineEventHandler(async event => {
}
}
} else {
/**
* 점검이 아닌 경우
* - 홈 경로는 no-cache
* - 점검 예정 시간에 따른 캐시 설정
* - 점검 5분 전: 짧은 캐시 (10초)
* - 점검 30분 전: 중간 캐시 (15초)
* - 점검 30분 이후: 기본 캐시 (60초)
*/
// 홈 경로: 캐시 없음
const isHomePath = [
'',
'/'
//, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`])
].includes(fullPath)
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
}
} else {
/**
* 점검이 아닌 경우
* - 홈 경로는 no-cache
* - 점검 예정 시간에 따른 캐시 설정
* - 점검 5분 전: 짧은 캐시 (10초)
* - 점검 30분 전: 중간 캐시 (15초)
* - 점검 30분 이후: 기본 캐시 (60초)
*/
// 홈 경로: 캐시 없음
const isHomePath = [
'',
'/',
//, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`])
].includes(fullPath)
if (isHomePath) {
setCacheHeaders(event, 'no-cache')
} else {
// 점검 예정 시간에 따른 캐시 설정
if (tsStartDate > 0 && timeUntilInspectionSeconds > 0) {
if (timeUntilInspectionSeconds < 300) {
// 점검 5분 전: 짧은 캐시 (10초)
setCacheHeaders(event, 'short', 10)
} else if (timeUntilInspectionSeconds < 1800) {
// 점검 30분 전: 중간 캐시 (15초)
setCacheHeaders(event, 'medium', 15)
} else {
// 점검 30분 이후: 기본 캐시 (60초)
setCacheHeaders(event, 'default')
}
if (isHomePath) {
setCacheHeaders(event, 'no-cache')
} else {
// 점검 예정 시간에 따른 캐시 설정
if (tsStartDate > 0 && timeUntilInspectionSeconds > 0) {
if (timeUntilInspectionSeconds < 300) {
// 점검 5분 전: 짧은 캐시 (10초)
setCacheHeaders(event, 'short', 10)
} else if (timeUntilInspectionSeconds < 1800) {
// 점검 30분 전: 중간 캐시 (15초)
setCacheHeaders(event, 'medium', 15)
} else {
// 점검 30분 이후: 기본 캐시 (60초)
setCacheHeaders(event, 'default')
}
}
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
}
} catch (error) {
console.error('gameData load error:', error)
}

View File

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

View File

@@ -23,7 +23,7 @@ export const useModalStore = defineStore('modalStore', () => {
}
const handleOpenAlert = ({
isShowDimmed = false,
isShowDimmed = true,
modalName = '',
isOutsideClose = false,
contentText,
@@ -47,7 +47,7 @@ export const useModalStore = defineStore('modalStore', () => {
}
const handleOpenConfirm = ({
isShowDimmed = false,
isShowDimmed = true,
modalName = '',
isOutsideClose = false,
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()
// Configuration
const config = useRuntimeConfig()
const dataResourcesUrl = config.public.dataResourcesUrl as string
const runtimeConfig = useRuntimeConfig()
const dataResourcesUrl = runtimeConfig.public.dataResourcesUrl as string
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
const multilingualFileName = 'STOVE_PUBTEMPLATE_homepage_brand_secure.json'
const stoveApiBaseUrl = config.public.stoveApiUrl
// Multilingual
const resultGetMultilingual = await useGetMultilingual({
@@ -33,7 +33,7 @@ const isLogin = ref(false)
const secureSetting = ref({
otpLoginYn: 'N',
abroadLoginBlockYn: 'N',
pcRegisterYn: 'N'
pcRegisterYn: 'N',
})
// 회원 보안 설정 설정
@@ -52,26 +52,28 @@ const checkLoginValidation = async () => {
// 회원 보안 설정 조회
const fnGetSecuritySetting = async () => {
const accessToken = useCookie('SUAT')
checkLoginValidation()
const apiBase = `${stoveApiBaseUrl}/auth-secure/v1.0`
const headers = {
Authorization: `Bearer ${accessToken.value}`,
'Content-Type': 'application/json;charset=UTF-8'
'Content-Type': 'application/json;charset=UTF-8',
}
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)) {
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 = {
otpLoginYn: getValue('OTP_LOGIN_YN'),
abroadLoginBlockYn: getValue('ABROAD_LOGIN_BLOCK_YN'),
pcRegisterYn: getValue('PC_REGISTER_YN')
pcRegisterYn: getValue('PC_REGISTER_YN'),
}
}
} catch (e) {
@@ -89,7 +91,9 @@ const secureCards = computed(() => {
{
id: 'SECURE_CARD_0',
title: tm('Secure_Stove_otp') || '스토브 인증기 (OTP)',
description: tm('Secure_Stove_otp_desc') || '스토브 앱으로 인증 후 안전하게 로그인하세요.',
description:
tm('Secure_Stove_otp_desc') ||
'스토브 앱으로 인증 후 안전하게 로그인하세요.',
status: secureSetting.value.otpLoginYn,
benefitTitle: tm('Secure_Stove_otp_benefits') || '스토브 OTP 혜택',
benefitDesc: tm('Secure_Defense_bonus_10') || '방어력 +10',
@@ -100,7 +104,9 @@ const secureCards = computed(() => {
{
id: 'SECURE_CARD_1',
title: tm('Secure_Block_foreign_login') || '해외 로그인 차단',
description: tm('Secure_Block_foreign_login_desc') || '접속 국가를 제한하여 의심 로그인을 차단해요.',
description:
tm('Secure_Block_foreign_login_desc') ||
'접속 국가를 제한하여 의심 로그인을 차단해요.',
status: secureSetting.value.abroadLoginBlockYn,
benefitTitle: '',
benefitDesc: '',
@@ -111,7 +117,9 @@ const secureCards = computed(() => {
{
id: 'SECURE_CARD_2',
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,
benefitTitle: '',
benefitDesc: '',
@@ -126,7 +134,6 @@ const cautionText = computed(() => {
return tm('Secure_Notice_Content') || []
})
onMounted(() => {
fnGetSecuritySetting()
})
@@ -143,12 +150,21 @@ onMounted(() => {
<section class="section-secure bg-[#F0F0F0] pb-50">
<div class="section-static content-standa md:max-w-[1300px] mx-auto">
<!-- Title Section -->
<div 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]">
<div
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') || '보안 서비스' }}
</h3>
<p 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
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>
</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"
>
<!-- 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 -->
<div class="inline-flex">
<span
@@ -171,17 +189,25 @@ onMounted(() => {
: 'bg-[#EBEBEB] text-[#999999]',
]"
>
{{ card.status === 'Y' ? tm('Secure_Enabled') : tm('Secure_Disabled') }}
{{
card.status === 'Y'
? tm('Secure_Enabled')
: tm('Secure_Disabled')
}}
</span>
</div>
<!-- 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 }}
</h4>
<!-- 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 }}
</p>
</div>
@@ -203,7 +229,9 @@ onMounted(() => {
class="w-[48px] h-[48px] bg-[#3C75FF] rounded-[8px] flex items-center justify-center"
>
<img
:src="getImageHost(card.benefitIcon, { imageType: 'common' })"
:src="
getImageHost(card.benefitIcon, { imageType: 'common' })
"
:alt="card.benefitTitle"
class="w-[48px] h-[48px] object-contain rounded-2xl"
loading="lazy"
@@ -211,7 +239,9 @@ onMounted(() => {
/>
</div>
<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 }}
</div>
<div
@@ -230,7 +260,11 @@ onMounted(() => {
button-size="size-small md:size-large"
background-color="#000000"
text-color="#FFFFFF"
@click="isLogin ? handleSecureSetting(card.url) : checkLoginValidation()"
@click="
isLogin
? handleSecureSetting(card.url)
: checkLoginValidation()
"
>
<span>{{ tm('Secure_Action_setup') }}</span>
</AtomsButton>
@@ -243,8 +277,19 @@ onMounted(() => {
disabled
>
<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">
<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
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>
</AtomsButton>
</div>
@@ -252,8 +297,12 @@ onMounted(() => {
</div>
<!-- Caution Section -->
<div 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]">
<div
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') }}
</h5>
<ul class="relative flex flex-col items-start justify-start w-full">
@@ -262,11 +311,10 @@ onMounted(() => {
:key="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]"
>
</li>
></li>
</ul>
</div>
</div>
</section>
</div>
</template>
</template>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import { globalDateFormat } from '@seed-next/date'
import {
getComponentGroup,
getComponentContainer,
} from '#layers/utils/dataUtil'
import { getYouTubeThumbnail } from '#layers/utils/youtubeUtil'
import { formatTimestamp } from '#layers/utils/formatUtil'
import type { Splide as SplideType } from '@splidejs/splide'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { OperateGroupItem } from '#layers/types/api/resourcesData'
@@ -61,7 +61,7 @@ const { data: slideData } = await useAsyncData(
})
const mediaList = getComponentContainer(operateGroupList, 'mediaList', {
hasGroup: true,
isGroup: true,
}) as OperateGroupItem[]
return mediaList ?? []
@@ -165,7 +165,7 @@ const handleLoadMoreRecent = () => {
<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]"
>
{{ formatTimestamp(item.reg_dt, 'YYYY.MM.DD') }}
{{ globalDateFormat(item.reg_dt, locale) }}
</p>
</div>
</SplideSlide>
@@ -209,7 +209,7 @@ const handleLoadMoreRecent = () => {
<p
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>
</div>
</li>
@@ -238,11 +238,11 @@ const handleLoadMoreRecent = () => {
@apply block;
}
.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
sm:bottom-[24px] md:bottom-[36px] md:w-[48px] md:h-[48px] lg:bottom-[60px];
@apply block top-[unset] bottom-[20px] translate-y-0 bg-cover bg-center bg-no-repeat
sm:bottom-[24px] md:bottom-[36px] lg:bottom-[60px];
}
.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)]
md:left-[calc(56%+39px)]
lg:left-[790px];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import { globalDateFormat } from '@seed-next/date'
import {
getComponentGroup,
getComponentContainer,
} from '#layers/utils/dataUtil'
import { formatTimestamp } from '#layers/utils/formatUtil'
import { getImageHost } from '#layers/utils/styleUtil'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
import type { OperateGroupItem } from '#layers/types/api/resourcesData'
@@ -51,7 +51,7 @@ const { data: slideData } = await useAsyncData(
})
const bannerList = getComponentContainer(operateGroupList, 'bannerList', {
hasGroup: true,
isGroup: true,
minLength: 4,
}) as OperateGroupItem[]
@@ -117,7 +117,7 @@ const onArrowClick = direction => {
<SplideSlide v-for="(item, index) in slideData" :key="index">
<BlocksCardNews
:title="item.title"
:description="formatTimestamp(item.reg_dt, 'YYYY.MM.DD')"
:description="globalDateFormat(item.reg_dt, locale)"
:img-path="getImageHost(item.img_path)"
:url="item.url"
: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
market_json: Record<string, { url: string }>
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
}
// 템플릿 컴포넌트 타입 - 가지 패턴
// 템플릿 컴포넌트 타입 - 가지 패턴
export type PageDataTemplateComponents =
| PageDataTemplateComponent // 단일 컴포넌트 패턴
| {

View File

@@ -44,7 +44,9 @@ const csrDownloadFile = (fileUrl: string, fileName?: string) => {
*/
const csrGetMarketingCode = () => {
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
}
@@ -82,15 +84,20 @@ const isNumeric = (str: string): boolean => {
* @param {Function} tm - i18n의 tm 함수 (예: (key) => ({ txt: string }))
* @param {any} query - 추가 쿼리 파라미터
*/
const getParsedCustomLink = (link: string, { tm, query = {} }: ParsedCustomLinkOptions) => {
const config = useRuntimeConfig()
const getParsedCustomLink = (
link: string,
{ tm, query = {} }: ParsedCustomLinkOptions
) => {
const runtimeConfig = useRuntimeConfig()
let result = `${link || ''}`
// @c{key} 패턴 치환 (예: @c{stoveCommunityUrl})
if (link.includes('@c')) {
result = result.replace(/@c\{(.*?)\}/g, (_, key) => {
// config.public에서 해당 key 값을 찾아 치환
return typeof config.public[key] === 'string' ? config.public[key] : ''
// runtimeConfig.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, {
expires: new Date(date),
path: '/'
path: '/',
})
setCookie.value = value
@@ -140,7 +147,9 @@ const setCookieForDay = (name: string, value: string, exp?: number) => {
// 정적 파일인지 확인하는 함수
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,
setCookieForDay,
isStaticFile,
isInRange
isInRange,
}

View File

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

View File

@@ -24,76 +24,6 @@ export const csrFormatJWT = (base64EncodeVal: string) => {
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)

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,8 @@ export default defineNuxtConfig({
stoveGnb: process.env.STOVE_GNB,
stoveCs: process.env.STOVE_CS,
stoveLauncherScript: process.env.STOVE_LAUNCHER_SCRIPT,
stoveClientDownloadUrl: process.env.STOVE_CLIENT_DOWNLOAD_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