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

This commit is contained in:
“hyeonggkim”
2025-11-03 14:36:41 +09:00
48 changed files with 2667 additions and 227 deletions

View File

@@ -13,7 +13,7 @@ STOVE_API_URL_SERVERL=https://i-api-dev.onstove.com
STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn-dev.onstove.com/libs/common-gnb/latest/cp-header.js
STOVE_GNB=https://js-cdn-dev.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js

View File

@@ -13,7 +13,7 @@ STOVE_API_URL_SERVERL=https://i-api.onstove.com
STOVE_M_API_URL=https://maintenance.onstove.com
# STOVE - GNB
STOVE_GNB=https://js-cdn.onstove.com/libs/common-gnb/latest/cp-header.js
STOVE_GNB=https://js-cdn.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.onstove.com/libs/stove-js-service/latest/launcher-pack.js

View File

@@ -13,7 +13,7 @@ STOVE_API_URL_SERVERL=https://i-api.gate8.com
STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn-qa.onstove.com/libs/common-gnb/latest/cp-header.js
STOVE_GNB=https://js-cdn-qa.onstove.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js

View File

@@ -13,7 +13,7 @@ STOVE_API_URL_SERVERL=https://i-api.gate8.com
STOVE_M_API_URL=https://maintenance.gate8.com
# STOVE - GNB
STOVE_GNB=https://js-cdn.gate8.com/libs/common-gnb/latest/cp-header.js
STOVE_GNB=https://js-cdn.gate8.com/libs/common-gnb/latest/stove-gnb.js
# STOVE - Client Download
STOVE_LAUNCHER_SCRIPT=https://js-cdn.gate8.com/libs/stove-js-service/latest/launcher-pack.js

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
@seed-next:registry=https://git.sginfra.net/api/v4/groups/4424/-/packages/npm/
# @stove-ui:registry=https://git.sginfra.net/api/v4/projects/557/packages/npm/

1
.nvmrc
View File

@@ -1 +1,2 @@
22.11.0

View File

@@ -9,6 +9,7 @@ const currentLayout = computed(() => getLayoutType(pageData.value))
definePageMeta({
layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화
middleware: ['inspection']
})
</script>

View File

@@ -9,6 +9,7 @@ const currentLayout = computed(() => getLayoutType(pageData.value))
definePageMeta({
layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화
middleware: ['inspection']
})
</script>

95
app/pages/error.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div class="min-h-screen flex flex-col bg-[#191919]">
<LayoutsStoveHeader />
<div class="flex-1 flex items-center justify-center bg-[#F0F0F0] pt-[223px] pb-[223px] px-5 max-[374px]:px-5 md:px-[530px]">
<div class="flex flex-col items-center gap-6 w-full max-w-[513px] max-[374px]:max-w-[335px] md:max-w-[860px] py-10 max-[374px]:gap-6 md:gap-6 md:py-20">
<!-- Stove Logo -->
<div class="flex items-center justify-center w-[123.2px] h-7">
<img
src="/images/common/logo-stove.svg"
alt="Stove"
class="w-full h-auto"
/>
</div>
<!-- Error Icon and Text -->
<div class="flex flex-col items-center gap-4 w-full">
<!-- Error Icon -->
<div class="flex items-center justify-center">
<img
src="/images/common/img_error.png"
alt="Error"
class="w-40 h-40 md:w-60 md:h-60"
/>
</div>
<!-- Error Text -->
<div class="flex flex-col items-center gap-1 w-full">
<h1 class="font-['Spoqa_Han_Sans_Neo',sans-serif] font-medium text-xl md:text-2xl leading-[1.5] md:leading-[1.4166666666666667] tracking-[-0.03em] text-center text-[#1F1F1F] m-0">
{{ errorTitle }}
</h1>
<p class="font-['Spoqa_Han_Sans_Neo',sans-serif] font-normal text-sm md:text-base leading-[1.7142857142857142] md:leading-[1.625] tracking-[-0.03em] text-center text-[#666666] m-0">
{{ errorDescription }}
</p>
</div>
</div>
<!-- Home Button -->
<button
class="flex items-center justify-center font-['Spoqa_Han_Sans_Neo',sans-serif] font-medium text-sm leading-[1.4285714285714286em] tracking-[-0.03em] text-white bg-[#FC4420] border border-black/10 rounded-lg px-10 h-12 cursor-pointer transition-all duration-300 backdrop-blur-[30px] shadow-[0px_0.9131946563720703px_1.4666459560394287px_0px_rgba(0,0,0,0.06),0px_2.194533586502075px_3.5245540142059326px_0px_rgba(0,0,0,0.09)] hover:bg-[#E03D1C] hover:shadow-[0px_0.9131946563720703px_1.4666459560394287px_0px_rgba(0,0,0,0.06),0px_2.194533586502075px_3.5245540142059326px_0px_rgba(0,0,0,0.09),0px_4.1321120262146px_6.636422634124756px_0px_rgba(0,0,0,0.12)] active:translate-y-px"
@click="handleGoHome"
>
{{ homeButtonText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import { useLocalePath } from '#i18n'
const props = defineProps({
error: Object as () => NuxtError,
})
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
const localePath = useLocalePath()
const gameName = computed(() => gameData.value?.game_name || '게임')
const homeButtonText = computed(() => `${gameName.value} 홈페이지 가기`)
const handleGoHome = () => {
const homePath = localePath('/')
navigateTo(homePath)
}
// 에러 상태 코드에 따른 메시지 설정
const errorTitle = computed(() => {
if (props.error?.statusCode === 404) {
return '페이지를 찾을 수 없어요.'
}
return '페이지를 찾을 수 없어요.'
})
const errorDescription = computed(() => {
if (props.error?.statusCode === 404) {
return '주소가 바뀌었거나 잘못 입력된 것 같아요. 주소를 다시 확인해 주세요.'
}
return '주소가 바뀌었거나 잘못 입력된 것 같아요. 주소를 다시 확인해 주세요.'
})
definePageMeta({
layout: 'only-stove'
})
</script>
<style scoped>
/* :deep(.game-wrapper) {
display: none !important;
} */
</style>

View File

@@ -7,6 +7,7 @@ const { pageData } = storeToRefs(pageDataStore)
const currentLayout = computed(() => getLayoutType(pageData.value))
definePageMeta({
layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화
})

View File

@@ -0,0 +1,380 @@
<template>
<header class="header">
<BlocksStoveGnbNew class="min-h-[48px]" />
</header>
<section class="inspection-section">
<clientOnly>
<!-- 로고 -->
<div class="inspection-logo">
<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 class="inspection-time text-sm md:text-base font-medium">
<div v-dompurify-html="getLocaleTimezone('', '')" class="time-row"></div>
</div>
</div>
<!-- 온스토브 & 다운로드 카드 -->
<div class="inspection-bottom-cards">
<!-- 온스토브 카드 -->
<div class="inspection-card inspection-stove-card">
<h3 :class="{ 'text-center': !launchingStatus }" class="card-title text-base md:text-lg">
<span v-if="!launchingStatus" v-dompurify-html="tm('Inspection_Game_During_Maintenance')"></span>
<span v-else v-dompurify-html="tm('org_Inspection_During_Maintenance')"></span>
</h3>
<div class="button-group justify-center">
<!-- <a
:href="communityUrl"
target="_blank"
rel="noopener noreferrer"
class="inspection-btn inspection-btn-outline inspection-btn-community"
:class="{ 'bg-[var(--primary)]' : !launchingStatus }"
>
<span>{{ tm('Inspection_Community_Btn') || '공식 커뮤니티' }}</span>
<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"
>
<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>
</div>
</div>
<!-- 다운로드 카드 -->
<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)"
class="h-[48px]"
:platform="btn.platform as any"
:url="btn.url"
:type="btn.platform === 'pc' ? 'default' : 'single'"
variant="outlined"
>
{{ getButtonText(btn.platform) }}
</AtomsButtonLauncher>
</div>
</div>
</div>
</div>
</div>
</clientOnly>
</section>
</template>
<script setup lang="ts">
import { globalDateFormat } from '@seed-next/date';
import { useCheckGameStart } from '#layers/composables/useGameStart'
const config = useRuntimeConfig()
const rootPath = config.public.staticUrl
const runType = config.public.runType
const translationApi = `${rootPath}/${runType}/test`
// const isClient = import.meta.client
const inspectionStore = useInspectionStore()
const { webInspectionData } = storeToRefs(inspectionStore)
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
const resultGetMultilingual = await useGetMultilingual({
baseApiUrl: translationApi,
fileName: 'test_common_inspection.json'
})
const { tm, locale } = useI18n({
useScope: 'local',
messages: Object(resultGetMultilingual.value.multilingual)
})
// locale에 따라 뒤에 KST 또는 UTC 추가 ko, en, zh-tw, ja
// ko: (KST)
// en: (UTC)
// zh-tw: 台灣時間 (KST)
// ja: (JST)
// 나머지: (KST)
const getLocaleTimezone = (localeType: string, region) => {
const tsStartDate = webInspectionData.value?.ts_start_date || 0
const tsEndDate = webInspectionData.value?.ts_end_date || 0
const currentLocale = localeType ? localeType : locale.value
switch (currentLocale) {
case 'ko':
return `
${globalDateFormat(new Date(tsStartDate), currentLocale, region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), currentLocale, region || 'KR', {useFullDate: true})} (KST)<br>
${globalDateFormat(new Date(tsStartDate), 'en', region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), 'en', region || '', {useFullDate: true})} (UTC)
`
case 'en':
return `${globalDateFormat(new Date(tsStartDate), currentLocale, region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), currentLocale, region || '', {useFullDate: true})} (UTC)`
case 'zh-tw':
return `
${globalDateFormat(new Date(tsStartDate), currentLocale, region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), currentLocale, region || '', {useFullDate: true})} (台灣時間)<br>
${globalDateFormat(new Date(tsStartDate), 'en', region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), 'en', region || '', {useFullDate: true})} (UTC)
`
case 'ja':
return `
${globalDateFormat(new Date(tsStartDate), currentLocale, region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), currentLocale, region || '', {useFullDate: true})} (JST)<br>
${globalDateFormat(new Date(tsStartDate), 'en', region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), 'en', region || '', {useFullDate: true})} (UTC)
`
default:
return `
${globalDateFormat(new Date(tsStartDate), currentLocale, region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), currentLocale, region || '', {useFullDate: true})} (KST)<br>
${globalDateFormat(new Date(tsStartDate), 'en', region || '', {useFullDate: true})} <br class="md:hidden">~ ${globalDateFormat(new Date(tsEndDate), 'en', region || '', {useFullDate: true})} (UTC)
`
}
}
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 },
// google_play: { url: 'https://play.google.com/store/apps/details?id=example', use_yn: 1 }
// }
//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 }))
})
const logoImgUrl = computed(() => {
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 localeData = (webInspectionData.value as any)?.[currentLocale]
return localeData?.url_json?.community
})
const handleCommunityClick = () => {
window.open(communityUrl.value, '_blank')
}
// 버튼 클래스 결정 함수
const getButtonClass = (platform: string) => {
// pc가 있으면 pc만 flex-1, 나머지는 기본
const hasPc = enabledMarkets.value.some(btn => btn.platform === 'pc')
if(hasPc) {
return platform === 'pc' ? `flex-1 btn-platform-pc` : ``
}
//pc가 없으면서 하나만 있으면 ''
if (enabledMarkets.value.length === 1) {
return ``
}
return `flex-1`
}
const getButtonText = (platform: string) => {
const hasPc = enabledMarkets.value.some(btn => btn.platform === 'pc')
// pc가 있으면 pc만 텍스트 노출
if (hasPc) {
return platform === 'pc' ? tm('platform_pc') : ''
}
// pc가 없으면 google_play와 app_store만 텍스트 노출
if (platform === 'google_play') {
return tm('platform_google_play')
}
if (platform === 'app_store') {
return tm('platform_app_store')
}
return ''
}
const { validateLauncher } = useCheckGameStart()
const handleGameStart = () => {
validateLauncher()
}
definePageMeta({
middleware: ['inspection'],
layout: 'only-stove',
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;
}
.inspection-logo {
@apply w-[944px] h-[50px] flex-shrink-0;
}
.inspection-logo img {
@apply w-full h-full object-contain;
}
.inspection-content {
@apply flex flex-col items-center gap-10 w-full max-w-[944px];
}
.inspection-title {
@apply text-center font-bold tracking-[-0.72px] text-[#1F1F1F];
}
.inspection-cards {
@apply flex flex-col gap-5 w-full;
}
.inspection-card {
@apply bg-white rounded-2xl p-6 md:p-8;
}
.inspection-time-card {
@apply flex flex-col items-center gap-4;
}
.card-title {
@apply text-center leading-[30px] font-bold tracking-[-0.6px] text-[#1F1F1F];
}
.inspection-time {
@apply flex flex-col items-center gap-0 md:gap-2;
}
.time-row {
@apply text-center leading-[26px] font-medium tracking-[-0.48px] text-[#1F1F1F];
}
.inspection-bottom-cards {
@apply flex flex-row gap-5 w-full;
}
.inspection-stove-card,
.inspection-download-card {
@apply flex flex-col justify-between gap-4 flex-1;
}
.inspection-stove-card .card-title {
@apply leading-[26px] font-bold tracking-[-0.54px] text-[#1F1F1F];
}
.inspection-download-card .card-title {
@apply text-left leading-[26px] font-bold tracking-[-0.54px] text-[#1F1F1F];
}
.inspection-content-text {
@apply text-left leading-[26px] font-medium tracking-[-0.48px] text-[#1F1F1F];
white-space: pre-line;
}
.button-group {
@apply flex flex-row gap-3 w-full;
}
.inspection-btn {
@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 {
@apply text-[#1F1F1F];
}
.inspection-btn-outline {
@apply bg-white;
}
.inspection-btn-outline:hover {
@apply bg-gray-50;
}
.inspection-btn-primary {
@apply bg-[var(--primary)] border-[var(--primary)] text-[#000];
}
.inspection-btn-primary span {
@apply text-black;
}
.inspection-btn-primary:hover {
@apply bg-[#B89D7A];
}
@media (max-width: 1024px) {
.inspection-section {
@apply px-5 py-20 pb-32;
}
.inspection-logo {
@apply w-full max-w-[944px];
}
.inspection-time-card {
@apply w-full;
}
.inspection-bottom-cards {
@apply flex-col;
}
.inspection-stove-card,
.inspection-download-card {
@apply w-full;
}
}
.inspection-btn-community {
@apply rounded-lg;
}
.inspection-btn-community.inspection-btn-outline {
@apply bg-white border-none;
}
.inspection-btn-community.inspection-btn-outline:hover {
@apply bg-gray-50;
}
</style>

95
error.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div class="min-h-screen flex flex-col bg-[#191919]">
<LayoutsStoveHeader />
<div class="flex-1 flex items-center justify-center bg-[#F0F0F0] pt-[223px] pb-[223px] px-5 max-[374px]:px-5 md:px-[530px]">
<div class="flex flex-col items-center gap-6 w-full max-w-[513px] max-[374px]:max-w-[335px] md:max-w-[860px] py-10 max-[374px]:gap-6 md:gap-6 md:py-20">
<!-- Stove Logo -->
<div class="flex items-center justify-center w-[123.2px] h-7">
<img
src="/images/common/logo-stove.svg"
alt="Stove"
class="w-full h-auto"
/>
</div>
<!-- Error Icon and Text -->
<div class="flex flex-col items-center gap-4 w-full">
<!-- Error Icon -->
<div class="flex items-center justify-center">
<img
src="/images/common/img_error.png"
alt="Error"
class="w-40 h-40 md:w-60 md:h-60"
/>
</div>
<!-- Error Text -->
<div class="flex flex-col items-center gap-1 w-full">
<h1 class="font-['Spoqa_Han_Sans_Neo',sans-serif] font-medium text-xl md:text-2xl leading-[1.5] md:leading-[1.4166666666666667] tracking-[-0.03em] text-center text-[#1F1F1F] m-0">
{{ errorTitle }}
</h1>
<p class="font-['Spoqa_Han_Sans_Neo',sans-serif] font-normal text-sm md:text-base leading-[1.7142857142857142] md:leading-[1.625] tracking-[-0.03em] text-center text-[#666666] m-0">
{{ errorDescription }}
</p>
</div>
</div>
<!-- Home Button -->
<button
class="flex items-center justify-center font-['Spoqa_Han_Sans_Neo',sans-serif] font-medium text-sm leading-[1.4285714285714286em] tracking-[-0.03em] text-white bg-[#FC4420] border border-black/10 rounded-lg px-10 h-12 cursor-pointer transition-all duration-300 backdrop-blur-[30px] shadow-[0px_0.9131946563720703px_1.4666459560394287px_0px_rgba(0,0,0,0.06),0px_2.194533586502075px_3.5245540142059326px_0px_rgba(0,0,0,0.09)] hover:bg-[#E03D1C] hover:shadow-[0px_0.9131946563720703px_1.4666459560394287px_0px_rgba(0,0,0,0.06),0px_2.194533586502075px_3.5245540142059326px_0px_rgba(0,0,0,0.09),0px_4.1321120262146px_6.636422634124756px_0px_rgba(0,0,0,0.12)] active:translate-y-px"
@click="handleGoHome"
>
{{ homeButtonText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { NuxtError } from '#app'
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import { useLocalePath } from '#i18n'
const props = defineProps({
error: Object as () => NuxtError,
})
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
const localePath = useLocalePath()
const gameName = computed(() => gameData.value?.game_name || '게임')
const homeButtonText = computed(() => `${gameName.value} 홈페이지 가기`)
const handleGoHome = () => {
const homePath = localePath('/')
navigateTo(homePath)
}
// 에러 상태 코드에 따른 메시지 설정
const errorTitle = computed(() => {
if (props.error?.statusCode === 404) {
return '페이지를 찾을 수 없어요.'
}
return '페이지를 찾을 수 없어요.'
})
const errorDescription = computed(() => {
if (props.error?.statusCode === 404) {
return '주소가 바뀌었거나 잘못 입력된 것 같아요. 주소를 다시 확인해 주세요.'
}
return '주소가 바뀌었거나 잘못 입력된 것 같아요. 주소를 다시 확인해 주세요.'
})
definePageMeta({
layout: 'only-stove'
})
</script>
<style scoped>
/* :deep(.game-wrapper) {
display: none !important;
} */
</style>

View File

@@ -1,10 +1,11 @@
export default defineI18nLocale(async (locale: string) => {
//https://static-pubcomm.gate8.com/dev/test/multilingual/test_common_template.json?20251021185116
const config = useRuntimeConfig()
const rootPath = config.public.staticUrl
const runType = config.public.runType
// const config = useRuntimeConfig()
// const rootPath = config.public.staticUrl
// const runType = config.public.runType
const translationApi = `${rootPath}/${runType}/test/multilingual/test_common_template.json`
// const translationApi = `${rootPath}/${runType}/test/multilingual/test_common_template.json`
const translationApi = `https://static-pubcomm.gate8.com/dev/test/multilingual/test_common_template.json`
try {
const { data } = await useFetch(translationApi, {
@@ -15,7 +16,7 @@ export default defineI18nLocale(async (locale: string) => {
})
// API 데이터에서 locale에 맞는 데이터를 추출
const apiData = data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
const apiData = data.value?.['ko'] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
// API 데이터와 common.json 데이터를 병합 (common.json이 우선순위)
const finalResult = { ...apiData }

View File

@@ -1,43 +1,124 @@
<template>
<div class="bg-white">
<select
v-model="selectedLocale"
<div class="select-language" :class="{ 'language-changing': isChanging }">
<button
:disabled="isChanging"
class="text-black px-2 py-1 rounded-md"
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 }"
@change="switchLanguage"
@click="toggleDropdown"
>
<option
v-for="localeOption in availableLanguages"
:key="localeOption"
:value="localeOption"
>
{{ localeOption }}
</option>
</select>
<span v-if="isChanging" class="ml-2 text-sm text-gray-500">
변경 중...
<!-- 지구본 아이콘 -->
<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"/>
</g>
<defs>
<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"/>
</circle>
</svg>
<!-- 드롭다운 화살표 -->
<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)"
>
<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>
</template>
<script setup lang="ts">
const gameDataStore = useGameDataStore()
const config = useRuntimeConfig()
const baseDomain = `${config.public.baseDomain}`
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
// 사용 가능한 언어 목록
const availableLanguages = computed(() => {
return gameDataStore.gameData?.lang_codes || ['ko']
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': '日本語',
'zh-cn': '简体中文',
'zh-tw': '繁體中文',
'es': 'Español',
'fr': 'Français',
'de': 'Deutsch',
'pt': 'Português',
'th': 'ไทย',
'it': 'Italiano'
}
return languageNames[localeCode] || localeCode
}
const { locale, setLocale } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const router = useRouter()
const pageDataStore = usePageDataStore()
const selectedLocale = ref(locale.value)
const isChanging = ref(false)
const isDropdownOpen = ref(false)
// 드롭다운 토글 함수
const toggleDropdown = () => {
if (!isChanging.value) {
isDropdownOpen.value = !isDropdownOpen.value
}
}
// 언어 선택 함수
const selectLanguage = async (localeCode: string) => {
if (localeCode === selectedLocale.value || isChanging.value) {
isDropdownOpen.value = false
return
}
// 언어 변경 함수 (CSR 방식)
selectedLocale.value = localeCode as any
isDropdownOpen.value = false
await switchLanguage()
}
// 언어 변경 함수 (서버 미드웨어를 통한 gameData 갱신)
const switchLanguage = async () => {
if (!selectedLocale.value || isChanging.value) return
@@ -45,22 +126,39 @@ const switchLanguage = async () => {
try {
// URL 경로를 통해 언어 변경
const path = switchLocalePath(selectedLocale.value)
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)
// await router.push(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를 다시 가져옴
// 페이지 새로고침을 통해 데이터 재로드 보장
await nextTick()
window.location.reload()
}
} catch {
// 오류 발생 시 이전 언어로 복원
selectedLocale.value = locale.value
// 페이드 효과 복원
document.body.style.opacity = '1'
} finally {
isChanging.value = false
}
@@ -71,3 +169,84 @@ watch(locale, newLocale => {
selectedLocale.value = newLocale
})
</script>
<style scoped>
.select-language {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 180px;
text-align: left;
font-size: 12px;
color: #ccc;
}
/* 페이지 전환 애니메이션 */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
/* 언어 변경 시 전체 페이지 페이드 효과 */
.language-changing {
opacity: 0.5;
transition: opacity 0.3s ease;
pointer-events: none;
}
/* 전체 페이지 전환 효과 */
body {
transition: opacity 0.5s ease-out;
}
.select-language select {
width: 100%;
height: 100%;
border: 1px solid #ccc;
border-radius: 5px;
padding: 0 10px;
font-size: 16px;
}
.dropdown-menu {
position: absolute;
bottom: 100%;
margin-bottom: 4px;
left: 0;
width: 100%;
background-color: #292929;
border: 1px solid #595959;
border-radius: 8px;
padding: 10px;
font-size: 12px;
color: #ccc;
}
.dropdown-menu-item-button {
display: flex;
align-items: center;
width: 100%;
height: 40px;
gap: 8px;
padding: 0 12px;
text-align: left;
background-color: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-menu-item-button svg {
opacity: 0;
}
.dropdown-menu-item-button.current {
color: #fc4420;
font-weight: 500;
}
.dropdown-menu-item-button.current svg {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div id="stove-wrapper" class="relative z-[5]" />
</template>
<script setup lang="ts">
import type {
GameDataValue,
} from '#layers/types/api/gameData'
let mountedInstance: any = null
onMounted(() => {
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const langCodes = gameData?.lang_codes
const defaultLangCode = gameData?.default_lang_code
const gnbData = gameData?.stove_gnb_json
const currentDomain = window.location.protocol + '//' + window.location.hostname;
if (typeof window !== 'undefined' && (window as any).StoveGnb) {
mountedInstance = (window as any).StoveGnb.mount('#stove-wrapper', {
logArea: currentDomain,
useLanguageCodeFromPath: true,
serviceTitle: {
pc: '',
mobile: ''
},
widget: {
notification: true,
stoveDownload: true,
languageSelect: false,
themeSelect: false,
stoveMenu: {
active: false,
mobile: true
},
},
global: {
languageCoverages: langCodes,
defaultSelectedLanguage: defaultLangCode || 'en',
},
loginMethod: {
redirectCurrentPage: true,
},
mode: {
theme: {
default: gnbData?.skin_type === 'gnb-dark-mini' ? 'dark' : 'light',
support: ['dark', 'light'],
},
mini: true,
fixed:false,
},
})
}
})
onBeforeUnmount(() => {
if (mountedInstance && typeof mountedInstance.destroy === 'function') {
mountedInstance.destroy()
}
mountedInstance = null
})
</script>

View File

@@ -24,12 +24,12 @@
</li>
<li class="relative">
<button class="hover:text-gray-600 transition-colors" @click="toggleAgeRating">
{{ tm('Footer_AgeRating') }}
{{ footerAgeRating }}
</button>
<div v-if="showAgeRating" class="game-rating-card absolute bottom-6 left-1/2 -translate-x-1/2 bg-[#383838] rounded-lg w-[340px] mx-auto z-10">
<!-- 헤더 -->
<div class="px-6 py-4 rounded-t-lg flex justify-between items-center">
<h3 class="text-white text-base">{{ tm('Footer_AgeRating') }}</h3>
<h3 class="text-white text-base">{{ footerAgeRating }}</h3>
<button class="text-white hover:text-gray-300 transition-colors" @click="toggleAgeRating">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@@ -55,23 +55,23 @@
<div class="px-6 py-6 rounded-b-lg bg-[#A31639]">
<div class="space-y-2">
<div class="flex flex-start border-b border-white/10 pb-2">
<span class="text-white text-sm flex-1">{{ tm('Footer_AgeRating_Info')[0] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.company_name }}</span>
<span class="text-white text-sm flex-1">{{ footerAgeRatingInfo[0] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.title }}</span>
</div>
<div class="flex flex-start border-b border-white/10 pb-2">
<span class="text-white text-sm flex-1">{{ tm('Footer_AgeRating_Info')[1] }}</span>
<span class="text-white text-sm flex-1">{{ footerAgeRatingInfo[1] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.rating_grade }}</span>
</div>
<div class="flex flex-start border-b border-white/10 pb-2">
<span class="text-white text-sm flex-1">{{ tm('Footer_AgeRating_Info')[2] }}</span>
<span class="text-white text-sm flex-1">{{ footerAgeRatingInfo[2] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.reg_no }}</span>
</div>
<div class="flex flex-start border-b border-white/10 pb-2">
<span class="text-white text-sm flex-1">{{ tm('Footer_AgeRating_Info')[3] }}</span>
<span class="text-white text-sm flex-1">{{ footerAgeRatingInfo[3] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.prod_date }}</span>
</div>
<div class="flex flex-start">
<span class="text-white text-sm flex-1">{{ tm('Footer_AgeRating_Info')[4] }}</span>
<span class="text-white text-sm flex-1">{{ footerAgeRatingInfo[4] }}</span>
<span class="text-white text-sm flex-1">{{ footerData.game_rating_info.rating_class_no }}</span>
</div>
</div>
@@ -85,7 +85,7 @@
<address class="not-italic text-gray-500">
<div class="row my-1.5 leading-5">
<span
v-dompurify-html="tm('Footer_Address')"
v-dompurify-html="footerAddress"
class="[&_a]:cursor-pointer [&_a]:text-blue-500 [&_a]:underline"
></span>
</div>
@@ -93,7 +93,7 @@
</div>
<div class="mt-6 md:mt-6">
<div class="text-xs text-white/30">{{ tm('Footer_caution') }}</div>
<div class="text-xs text-white/30">{{ footerCaution }}</div>
</div>
<div class="copyright-area mt-6 text-gray-500 md:mt-4">
@@ -114,20 +114,15 @@
</a>
<a
v-if="setDevCi.dev_ci_yn"
:href="footerData.use_dev_ci_url"
:href="footerData.use_dev_ci_url ? setDevCi.dev_ci_img_path : '#'"
target="_blank"
class="nx3 ml-2.5 md:ml-4"
>
<img
src="https://static-pubcomm.gate8.com/local/template/l9/common/logo_nx3.png"
:src="`${staticUrl}${setDevCi.dev_ci_img_path}`"
alt="CI"
class="w-auto h-auto"
class="w-auto h-[24px]"
/>
<!-- <img
:src="setDevCi.dev_ci_img_path"
alt="CI"
class="w-auto h-auto"
/> -->
</a>
</div>
@@ -144,74 +139,106 @@
</template>
<script setup lang="ts">
const { tm } = useI18n()
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
import type { FooterMenuItem, FooterData, DevCiConfig } from '#layers/types/Common'
const config = useRuntimeConfig()
const staticUrl = config.public.staticUrl
const runType = config.public.runType
const translationApi = `${runType}/test/multilingual/test_common_template.json`
const result = await useApiData({ baseApiUrl: staticUrl, url: translationApi })
const { tm } = useI18n({
useScope: 'local',
messages: result
})
// const { tm } = useI18n()
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
// const path = ref<string>(`${staticUrl}/local/template/${gameData.value.s3_folder_name}`)
// 공통다국어 data
const footerLinks = computed(() => {
return tm('Footer_Menu') as unknown as any[]
const footerLinks = computed((): FooterMenuItem[] => {
const menu = (tm as any)('Footer_Menu')
return Array.isArray(menu) ? menu as FooterMenuItem[] : []
})
const footerData = ref(gameData.value?.footer_json as any)
const setDevCi = ref({
const footerData = ref(gameData.value?.footer_json as unknown as FooterData)
const setDevCi = ref<DevCiConfig>({
dev_ci_yn: gameData.value?.footer_dev_ci_img_yn as boolean,
dev_ci_img_path: gameData.value?.footer_dev_ci_img_path as string,
})
///local/template/common/grades_age
const getGameRatingImage = computed(() => {
const getGameRatingImage = computed((): string[] => {
const contentInfo = footerData.value.game_rating_info.rating_type.split(',')
// rating_type 12, 15, 18, 19 에 따라 이미지명을 가져오고 이미지를 반환
return contentInfo.map(item => {
if (item === '12') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/Type12.svg`
} else if (item === '15') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/Type15.svg`
} else if (item === '19') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/Type19.svg`
} else if (item === 'all') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/TypeAll.svg`
} else if (item === 'e') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/TypeExempt.svg`
} else {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_age/TypeTest.svg`
switch (item) {
case '12':
return getImageHost('/images/common/grades_age/Type12.svg', { imageType: 'common' })
case '15':
return getImageHost('/images/common/grades_age/Type15.svg', { imageType: 'common' })
case '19':
return getImageHost('/images/common/grades_age/Type19.svg', { imageType: 'common' })
case 'all':
return getImageHost('/images/common/grades_age/TypeAll.svg', { imageType: 'common' })
case 'e':
return getImageHost('/images/common/grades_age/TypeExempt.svg', { imageType: 'common' })
default:
return getImageHost('/images/common/grades_age/TypeTest.svg', { imageType: 'common' })
}
})
})
const getContentInfoImage = computed(() => {
const getContentInfoImage = computed((): string[] => {
const contentInfo = footerData.value.game_rating_info.content_info.split(',')
contentInfo.pop()
return contentInfo.map(item => {
if (item === '1') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-sexual.svg`
} else if (item === '2') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-fear.svg`
} else if (item === '3') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-inapposite.svg`
} else if (item === '4') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-drug.svg`
} else if (item === '5') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-crime.svg`
} else if (item === '6') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-speculation.svg`
} else if (item === '7') {
return `${staticUrl}/local/template/${gameData.value.s3_folder_name}/common/grades_use/Type-violence.svg`
switch (item) {
case '1':
return getImageHost('/images/common/grades_use/Type-sexual.svg', { imageType: 'common' })
case '2':
return getImageHost('/images/common/grades_use/Type-fear.svg', { imageType: 'common' })
case '3':
return getImageHost('/images/common/grades_use/Type-inapposite.svg', { imageType: 'common' })
case '4':
return getImageHost('/images/common/grades_use/Type-drug.svg', { imageType: 'common' })
case '5':
return getImageHost('/images/common/grades_use/Type-crime.svg', { imageType: 'common' })
case '6':
return getImageHost('/images/common/grades_use/Type-speculation.svg', { imageType: 'common' })
case '7':
return getImageHost('/images/common/grades_use/Type-violence.svg', { imageType: 'common' })
}
})
})
const showAgeRating = ref(false)
const toggleAgeRating = () => {
const showAgeRating = ref<boolean>(false)
const toggleAgeRating = (): void => {
showAgeRating.value = !showAgeRating.value
}
// 템플릿에서 사용할 다국어 텍스트들
const footerAgeRating = computed((): string => {
const text = (tm as any)('Footer_AgeRating')
return typeof text === 'string' ? text : ''
})
const footerAgeRatingInfo = computed((): string[] => {
const info = (tm as any)('Footer_AgeRating_Info')
return Array.isArray(info) ? info : []
})
const footerAddress = computed((): string => {
const address = (tm as any)('Footer_Address')
return typeof address === 'string' ? address : ''
})
const footerCaution = computed((): string => {
const caution = (tm as any)('Footer_caution')
return typeof caution === 'string' ? caution : ''
})
</script>
<style scoped>

View File

@@ -171,7 +171,7 @@ onBeforeUnmount(() => {
<template>
<header class="header">
<BlocksStoveGnb class="min-h-[48px]" />
<BlocksStoveGnbNew class="h-[48px]" />
<div class="game-wrap" :class="{ 'is-fixed': isPassedStoveGnb }">
<AtomsLocaleLink to="/brand" class="mx-auto md:hidden">

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
</script>
<template>
<header class="header">
<BlocksStoveGnbNew class="min-h-[48px]" />
</header>
</template>
<style scoped>
.header {
@apply bg-theme-foreground text-theme-foreground-reversal relative z-[100];
}
</style>
c

View File

@@ -0,0 +1,22 @@
interface ReqApiData {
baseApiUrl: string
url: string
}
export const useApiData = async (req: ReqApiData): Promise<any> => {
const dataUrl = `${req.baseApiUrl}/${req.url}` // 정상 URL 경로
try {
const fetch = await $fetch<any>(dataUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
return fetch
} catch (error) {
console.log('error', error)
return []
}
}

View File

@@ -0,0 +1,54 @@
import type { GameDataResponse, GameDataRequest } from '#layers/types/api/gameData'
export const useGetGameDataExternal = () => {
const { setGameData } = useGameDataStore()
const logPrefix = {
exception: '[Exception] /composables/useGetGameDataExternal',
failure: '[Failure] /composables/useGetGameDataExternal'
}
const webGameData = ref<GameDataResponse | null>(null)
const getGameDataExternal = async (req: GameDataRequest) => {
console.log("🚀 ~ getGameDataExternal ~ req:", req)
// const config = useRuntimeConfig()
const config = useRuntimeConfig()
const stoveApiUrl = `${config.public.stoveApiUrl}`
const apiUrl = `${stoveApiUrl}/pub-comm/v1.0/template/game?game_domain=${req.gameDomain}&lang_code=${req.langCode}`
try {
const response = (await commonFetch('GET', apiUrl)) as GameDataResponse
console.log("🚀 ~ getGameDataExternal ~ response:", response)
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
response.value = {
inspection_status: 1,
inspection: {
inspection_status: 1,
start_date: '2025-09-19 10:00:00',
end_date: '2025-09-19 12:00:00',
ts_start_date: new Date().getTime(),
ts_end_date: new Date().getTime(),
back_ground_image_type: 'image',
back_ground_image_url: 'https://www.onstove.com',
inspection_title1: '',
inspection_title2: ''
}
}
} */
// ------------------------------------------------------------------------
if (response?.value) {
webGameData.value = response
setGameData(response.value)
}
} catch (e) {
console.error(`${logPrefix.exception}.getGameDataExternal: `, e)
}
}
return { webGameData, getGameDataExternal }
}

View File

@@ -0,0 +1,97 @@
import type { ReqGameMaintenance, ResGameMaintenance } from '#layers/types/GameMaintenanceType'
/**
* 게임 점검
*/
const useGetGameMaintenance = () => {
const inspectionStore = useInspectionStore()
const logPrefix = {
exception: '[Exception] /composables/useGetGameMaintenance',
failure: '[Failure] /composables/useGetGameMaintenance'
}
const isGameMaintenance = ref(false) // 게임 서버 점검 여부
// [Setter] 게임 서버 점검 여부 세팅
const setIsGameMaintenance = (status: boolean) => {
isGameMaintenance.value = status
}
// 게임 점검이 아닌 경우 일괄 세팅
const setGameMaintenanceFalse = () => {
setIsGameMaintenance(false)
inspectionStore.setGameMaintenanceStatus(false)
inspectionStore.setGameMaintenanceData({ ts_start_date: 0, ts_end_date: 0, detail_link: '' })
}
/**
* 게임 서버 점검 여부
*
* @param {ReqGameMaintenance} req
* @description https://wiki.smilegate.net/pages/viewpage.action?pageId=362619887
*/
const checkGameMaintenance = async (req: ReqGameMaintenance) => {
let res: ResGameMaintenance = {} as ResGameMaintenance
try {
const baseApiUrl = req.baseApiUrl || ''
// Path Variables
const category = req.category || 'GAME'
const serviceId1 = req.service_id1 || ''
const lang = req.lang || 'ko'
const url = `${baseApiUrl}/v2.0/maintenances/${category}/${serviceId1}/${lang}`
res = (await commonFetch('GET', url, {})) as ResGameMaintenance
if (res != null && res.code === 0) {
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* const config = useRuntimeConfig()
if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
res.value = {
total_count: 1,
list: [
{
start_at: new Date().getTime(),
end_at: new Date().getTime(),
languages: [{ link: 'https://www.onstove.com', lang: 'ko', title: '', content: '' }],
maintenance_no: 0,
category: '',
service_id1: '',
service_id2: [],
type: '',
description: ''
}
]
}
} */
// ------------------------------------------------------------------------
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 || ''
})
inspectionStore.setGameMaintenanceStatus(true)
} else {
setGameMaintenanceFalse()
}
} else {
// [500] 내부 서버 에러
// [70001] 부적절한 엑세스 토큰
// [70051] 부적절한 파라미터 요청 - {param_key}
// [70052] 데이터를 찾을 수 않음
setGameMaintenanceFalse()
}
} catch (e) {
console.error(`${logPrefix.exception}.checkGameMaintenance: `, e)
res = { code: -99999, message: `${e}` }
setGameMaintenanceFalse()
}
return res
}
return { isGameMaintenance, checkGameMaintenance }
}
export { useGetGameMaintenance }

View File

@@ -0,0 +1,70 @@
import type { WebInspectionData, ReqGetInspectionData, ResGetInspectionData } from '#layers/types/InspectionType'
/**
* 웹 점검
*/
export const useGetInspectionDataExternal = () => {
const inspectionStore = useInspectionStore()
const logPrefix = {
exception: '[Exception] /composables/useGetInspectionDataExternal',
failure: '[Failure] /composables/useGetInspectionDataExternal'
}
const webInspectionData = ref<WebInspectionData | null>(null)
const isWebInspection = ref(false) // 웹 점검 여부
// [Setter] 웹 점검 여부 세팅
const setIsWebInspection = (status: boolean) => {
isWebInspection.value = status
}
/**
* 웹 점검 여부
*
* @param {ReqGetInspectionData} req
* @description https://wiki.smilegate.net/pages/viewpage.action?pageId=563198067
*/
const getInspectionDataExternal = async (req: ReqGetInspectionData) => {
// const config = 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)
// FIXME: 테스트용 데이터 ---------------------------------------------------
/* if (['local', 'local-gate8', 'dev'].includes(`${config.public.runType}`)) {
response.value = {
inspection_status: 1,
inspection: {
inspection_status: 1,
start_date: '2025-09-19 10:00:00',
end_date: '2025-09-19 12:00:00',
ts_start_date: new Date().getTime(),
ts_end_date: new Date().getTime(),
back_ground_image_type: 'image',
back_ground_image_url: 'https://www.onstove.com',
inspection_title1: '',
inspection_title2: ''
}
}
} */
// ------------------------------------------------------------------------
if (response?.value && response.value.inspection) {
webInspectionData.value = response.value.inspection
isWebInspection.value = response.value.inspection_status === 1
inspectionStore.setWebInspectionData(webInspectionData.value)
inspectionStore.setWebInspectionStatus(isWebInspection.value)
}
} catch (e) {
console.error(`${logPrefix.exception}.getInspectionDataExternal: `, e)
}
if (webInspectionData.value !== null) {
setIsWebInspection(isWebInspection.value)
}
}
return { webInspectionData, isWebInspection, getInspectionDataExternal }
}

View File

@@ -0,0 +1,6 @@
<script setup lang="ts"></script>
<template>
<LayoutsStoveHeader />
<slot />
</template>

View File

@@ -1,4 +1,4 @@
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type { GameDataRequest, GameDataValue } from '#layers/types/api/gameData'
export default defineNuxtRouteMiddleware(async (to, _from) => {
// 서버 사이드에서는 스킵
@@ -6,13 +6,6 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
return
}
const gameDataStore = useGameDataStore()
// gameData가 로드되지 않았으면 스킵 (다른 미들웨어에서 로드됨)
if (!gameDataStore.gameData) {
return
}
// 현재 경로에서 언어 코드 추출
// 예: /ko/about/story -> ko
// 예: /en/test/page -> en
@@ -20,10 +13,23 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const match = to.path.match(languagePattern)
const currentLangCode = match ? match[1] : null
// 허용된 언어 코드 목록
const allowedLangCodes = gameDataStore.gameData.lang_codes || []
//현재 url에서 게임 도메인만 추출
const currentDomain = window.location.hostname;
const req: GameDataRequest = {
gameDomain: `${currentDomain}`,
langCode: `${currentLangCode}`,
}
const { getGameDataExternal } = useGetGameDataExternal()
await getGameDataExternal(req)
// 현재 언어가 허용된 언어 목록에 없으면 404로 리다이렉트
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const langCodes = gameData?.lang_codes
// 허용된 언어 코드 목록
const allowedLangCodes = langCodes || []
// 현재 언어가 허용된 언어 목록에 없으면 에러 페이지로 이동
if (currentLangCode && !allowedLangCodes.includes(currentLangCode)) {
throw createError({
statusCode: 404,

View File

@@ -0,0 +1,46 @@
export default defineNuxtRouteMiddleware(async (to) => {
try {
if (import.meta.client) {
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
console.log("🚀 ~ 00000 gameData:", gameData.value)
const config = useRuntimeConfig()
// const baseDomain = `${config.public.baseDomain}`
const stoveApiUrl = `${config.public.stoveApiUrl}`
const stoveGameId = `${gameData.value.game_id}`
// const stoveMaintenanceApiUrl = `${config.public.stoveMaintenanceApiUrl}`
// const localeCookie = useCookie('LOCALE', {
// domain: baseDomain
// })
const finalLocale = csrGetFinalLocale(to.path)
// localeCookie.value = finalLocale.toUpperCase()
// 웹 점검 -----
const { isWebInspection, getInspectionDataExternal } = useGetInspectionDataExternal()
await getInspectionDataExternal({ baseApiUrl: stoveApiUrl, gameId: stoveGameId })
// 게임 점검 -----
// const { checkGameMaintenance } = useGetGameMaintenance()
// await checkGameMaintenance({
// baseApiUrl: stoveMaintenanceApiUrl,
// category: 'GAME',
// service_id1: stoveGameId,
// lang: `${finalLocale}`.toLowerCase()
// })
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) {
// 점검이 종료된 후 점검 페이지 접근시 메인으로 리다이렉트
return navigateTo(`/${finalLocale}`, { external: true })
}
}
} catch (e) {
console.error('[Exception] /middleware/inspection: ', e)
}
})

View File

@@ -16,6 +16,10 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page`
try {
if(to.path.includes('inspection')) {
return
}
const pageUrl = getPathAfterLanguage(to.path)
// pageUrl이 빈값이거나 null이면 /brand로 리다이렉트
@@ -36,9 +40,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
loading: true,
})) as PageDataResponse | null
console.log("🚀 ~ response?.code:", response?.code)
// if(response?.code === 91003) {
// throw createError({
// statusCode: 404,
// statusMessage: 'Page not found',
// })
// }
if (response?.code === 0 && 'value' in response) {
store.setPageData(response.value)
console.log('🚀 ~ pageData:', response.value)
// console.log('🚀 ~ pageData:', response.value)
} else {
store.clearPageData()
}

View File

@@ -0,0 +1,12 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
console.log("🚀 000000 ~ error:", error)
// handle error, e.g. report to a service
}
// Also possible
nuxtApp.hook('vue:error', (error, instance, info) => {
console.log("🚀1111 ~ error:", error)
// handle error, e.g. report to a service
})
})

View File

@@ -0,0 +1,11 @@
import { getTrueClientIp } from '#layers/utils/apiUtil'
export default defineEventHandler((event) => {
let clientIP = ''
try {
clientIP = getTrueClientIp(event.node.req)
} catch (e) {
console.error('[Exception] /server/api/clientIp - Cannot Get Client IP: ', e)
}
return clientIP || ''
})

View File

@@ -1,3 +1,4 @@
import { LRUCache } from 'lru-cache'
import {
getHeader,
getRequestHost,
@@ -6,24 +7,179 @@ import {
} from 'h3'
import { ssrGetFinalLocale } from '../../utils/localeUtil'
import type { GameDataResponse } from '../../types/api/gameData'
import type { ResGetInspectionData } from '../../types/InspectionType'
import { isStaticFile } from '#layers/utils/commonUtil'
/**
* 캐시 제어 헤더를 설정하는 공통 함수
*
* @param event - 이벤트 객체
* @param cacheMode - 캐시 모드 설정 ('no-cache', 'short', 'medium', 'default')
* @param customMaxAge - 커스텀 max-age 값 (초 단위)
*/
function setCacheHeaders(
event: { node: { res: { setHeader: (name: string, value: string) => void } } },
cacheMode: 'no-cache' | 'short' | 'medium' | 'default',
customMaxAge?: number
): void {
// 원래 setHeader 함수 참조 저장
const originalSetHeader = event.node.res.setHeader
// Cache-Control 헤더 설정값 결정
let cacheControl: string
switch (cacheMode) {
case 'no-cache':
cacheControl = 'no-cache, no-store, must-revalidate'
// no-cache 모드일 때는 추가 헤더도 설정
event.node.res.setHeader('Pragma', 'no-cache')
event.node.res.setHeader('Expires', '0')
break
case 'short':
cacheControl = `public, max-age=${customMaxAge || 10}`
break
case 'medium':
cacheControl = `public, max-age=${customMaxAge || 15}`
break
case 'default':
default:
cacheControl = `public, max-age=${customMaxAge || 60}`
break
}
// Cache-Control 헤더를 강제로 설정하기 위해 setHeader 메소드 오버라이드
event.node.res.setHeader = function (name: string, value: string) {
if (name.toLowerCase() === 'cache-control') {
return originalSetHeader.call(this, name, cacheControl)
}
return originalSetHeader.call(this, name, value)
}
// 바로 캐시 제어 헤더 적용
event.node.res.setHeader('Cache-Control', cacheControl)
}
const cache = new LRUCache({
max: 100, // 캐시에 저장할 최대 항목 수
ttl: 1000 * 30 // 30초 동안 캐시 유지
})
/**
* 최종 언어 쿠키 세팅
*
* @param event - 이벤트 객체
* @param finalLocale - 최종 언어
* @param baseDomain - 기본 도메인
*/
function setFinalLocaleCookie(event: any, finalLocale: string, baseDomain: string) {
setCookie(event, 'LOCALE', finalLocale.toUpperCase(), {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365 // 1년 (초 단위)
})
}
/**
* Locale Middleware 역할 함수
*
* @param event - 이벤트 객체
* @param finalLocale - 최종 언어
*/
function fnLocaleMiddleware(event: any, finalLocale: string) {
const path = event?.node.req.url || ''
let arrPath = []
let queryString = ''
if (path.includes('?')) {
// 쿼리스트링 포함 시 순수 경로만 추출
arrPath = path.split('?')[0].split('/')
queryString = path.split('?')[1]
} else {
arrPath = path.split('/')
queryString = ''
}
// 최종 언어 세팅된 경로 생성
const pathLocale = arrPath.length > 1 ? arrPath[1] : ''
// URL에서 현재 언어와 최종 언어가 다르면 리다이렉트
if (pathLocale !== finalLocale) {
let newLocalePath = ''
if (pathLocale === '') {
newLocalePath = `/${finalLocale}`
} else {
arrPath[1] = finalLocale
newLocalePath = arrPath.join('/')
}
if (queryString !== '') {
newLocalePath += `?${queryString}`
}
event.node.res.statusCode = 302
event.node.res.setHeader('Location', newLocalePath)
event.node.res.end()
}
}
export default defineEventHandler(async event => {
const url = getRequestURL(event)
const config = useRuntimeConfig()
// const runType = `${config.public.runType}`
const iBaseApiUrl = `${config.public.stoveApiUrlServer}`
const baseDomain = `${config.public.baseDomain}`
// console.log("🚀 ~ baseDomain:", config.public.baseDomain)
// 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)
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
// fnLocaleMiddleware(event, finalLocale)
// } catch (e) {
// console.error('[Exception] /server/middleware/middleware-02-global: ', e)
// }
// }
const fullPath = event.path
// 1-1. 정적 파일 패스
if (isStaticFile(event.path)) {
return
}
// 1-2. /inspection 패스
if (fullPath.includes('/inspection')) {
// 리턴 되기 전 언어 쿠키 세팅
const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
setFinalLocaleCookie(event, finalLocale, baseDomain)
return
}
// 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리
if (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/_nuxt/') ||
url.pathname.startsWith('/favicon') ||
url.pathname.includes('.') ||
url.pathname.startsWith('/_')
fullPath.startsWith('/api/') ||
fullPath.startsWith('/_nuxt/') ||
fullPath.startsWith('/favicon') ||
fullPath.includes('/assets/') ||
fullPath.includes('.') ||
fullPath.startsWith('/_')
) {
return
}
// 캐시 키 생성
const cacheKey = 'inspection'
// console.log("🚀 11111 ~ cacheKey:", cacheKey)
const host =
(getHeader(event, 'host') || getRequestHost(event)).toString() || ''
const baseDomain = process.env.BASE_DOMAIN || '.onstove.com'
const isGameDomainExtractable = host.includes(baseDomain)
if (isGameDomainExtractable) {
@@ -37,38 +193,139 @@ export default defineEventHandler(async event => {
const stoveApiUrlServer = config.public.stoveApiUrlServer
const apiUrl = `${stoveApiUrlServer}/pub-comm/v1.0/template/game`
const langCode = ssrGetFinalLocale(
event?.node.req.url,
event.node.req.headers
)
// console.log("🚀 ~ apiUrl:", apiUrl)
// 2. 언어 코드 추출
const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
// URL의 첫 번째 path를 lang_code로 사용 (파비콘, API 경로 제외)
// const pathSegments = url.pathname
// .split('/')
// .filter(
// segment =>
// segment &&
// !segment.includes('favicon') &&
// !segment.includes('api') &&
// !segment.startsWith('_')
// )
// const langCode = pathSegments[0] || 'ko'
const queryParams: Record<string, string> = {
game_domain: event.context.gameDomain || '',
lang_code: langCode,
lang_code: finalLocale,
}
const response = (await $fetch(apiUrl, {
query: queryParams,
})) as GameDataResponse | null
// 언어패스 쿠키 굽기 - 장기방안에서는 굽지않음
// const langCoverages = response?.value?.lang_codes || []
// if(langCoverages.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)
// console.log('🚀 ~ gameData:', response.value)
// 점검 데이터 조회
let inspectionData
if (cache.has(cacheKey)) {
inspectionData = cache.get(cacheKey) as WebInspectionData
} else {
// 점검 데이터 조회
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'
}
})
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)
// 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_list?.includes(clientIP)) {
// 허용되지 않은 IP인 경우 점검 페이지로 이동
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
} else {
// 화이트 리스트인 경우
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
} else {
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')
}
}
}
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
}
} catch (error) {
console.error('gameData load error:', error)
}

View File

@@ -0,0 +1,68 @@
import type { RenderResponse } from 'nitropack'
import type { H3Event } from 'h3'
import { defineNitroPlugin } from 'nitropack/runtime'
import { getTrueClientIp } from '#layers/utils/apiUtil'
function generateRequestId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2)
}
function getIpAddress(event: H3Event): string {
return getTrueClientIp(event.node.req as any) || 'unknown'
}
export default defineNitroPlugin((nitroApp) => {
// 정적 파일 체크 함수 추가
const isStaticFile = (path: string): boolean => {
return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/i.test(path)
}
// 헬스체크 경로 체크 함수 추가
const isHealthCheck = (path: string): boolean => {
return path === '/health'
}
nitroApp.hooks.hook('request', (event) => {
// 정적 파일 요청은 로깅 제외
if (isStaticFile(event.path) || isHealthCheck(event.path)) {
return
}
// 상세 로깅을 위한 정보 수집
const startTime = Date.now()
const userAgent = event.node.req.headers['user-agent'] || ''
const method = event.method || ''
const headers = JSON.stringify(event.node.req.headers, null, 2)
const requestId = generateRequestId()
if (process.env.NODE_ENV !== 'development') {
console.log(
`Request Info {"requestId":"${requestId}", "type":"request","method":"${method}","url":"${event.path}","userIp":"${getIpAddress(event)}","userAgent":"${userAgent}", "headers" : "${headers}" }`
)
// 요청 완료 후 응답 상태 코드 로깅
event.node.res.on('finish', () => {
console.log(
`Response Info {"requestId":"${requestId}","type":"response","method":"${method}","url":"${event.path}","statusCode":${event.node.res.statusCode},"responseTime":"${Date.now() - startTime}ms","userIp":"${getIpAddress(event)}","userAgent":"${userAgent}","statusMessage":"${event.node.res.statusMessage}","responseHeader": ${JSON.stringify(event.node.res.getHeaders(), null, 2)}}`
)
console.log(
'==========================================================================================================================================================================================================================================================='
)
})
}
})
nitroApp.hooks.hook('error', (error) => {
console.error('[Nitro Error]', {
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
})
})
// 응답 헤더에서 'x-powered-by' 제거
nitroApp.hooks.hook('render:response', (response: Partial<RenderResponse>) => {
if (response?.headers) {
delete response.headers['x-powered-by']
}
})
})

View File

@@ -0,0 +1,36 @@
import type { WebInspectionData } from '#layers/types/InspectionType'
import type { GameMaintenanceData } from '#layers/types/GameMaintenanceType'
export const useInspectionStore = defineStore('inspection', () => {
const webInspectionData = ref<WebInspectionData | null>(null) // 웹 점검 정보
const webInspectionStatus = ref<boolean | null>(null) // 웹 점검 상태
const gameMaintenanceData = ref<GameMaintenanceData | null>(null) // 게임 점검 정보
const gameMaintenanceStatus = ref<boolean | null>(null) // 게임 점검 상태
const setWebInspectionData = (data: WebInspectionData) => {
webInspectionData.value = data
}
const setWebInspectionStatus = (status: boolean) => {
webInspectionStatus.value = status
}
const setGameMaintenanceData = (data: GameMaintenanceData) => {
gameMaintenanceData.value = data
}
const setGameMaintenanceStatus = (status: boolean) => {
gameMaintenanceStatus.value = status
}
return {
webInspectionData,
webInspectionStatus,
gameMaintenanceData,
gameMaintenanceStatus,
setWebInspectionData,
setWebInspectionStatus,
setGameMaintenanceData,
setGameMaintenanceStatus
}
})

View File

@@ -0,0 +1,13 @@
export const useCallerInfoStore = defineStore('callerInfoStore', () => {
const callerId = ref<string | null>('')
const callerDetail = ref<string | null>('')
const setCallerId = (paramCallerId: string | null) => {
callerId.value = paramCallerId
}
const setCallerDetail = (paramCalleDetail: string | null) => {
callerDetail.value = paramCalleDetail
}
return { callerId, callerDetail, setCallerId, setCallerDetail }
})

View File

@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import { useWindowSize, useWindowScroll } from '@vueuse/core'
interface DeviceMode {
mode: 'desktop' | 'mobile'
browser: 'chrome' | 'crawler' | 'edge' | 'firefox' | 'safari' | null
isDesktop: boolean
isMobile: boolean
isTablet: boolean
isIos: boolean
isAndroid: boolean
isDeviceReady: boolean
}
export const useCommonStore = defineStore('commonStore', () => {
const stoveGnbHeight = 48
const useDeviceData = useDevice()
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' })
const device = ref<DeviceMode>({
mode: useDeviceData.isMobile || useDeviceData.isTablet ? 'mobile' : 'desktop',
browser: useDeviceData.isChrome
? 'chrome'
: useDeviceData.isCrawler
? 'crawler'
: useDeviceData.isEdge
? 'edge'
: useDeviceData.isFirefox
? 'firefox'
: useDeviceData.isSafari
? 'safari'
: null,
isDesktop: useDeviceData.isDesktop,
isMobile: useDeviceData.isMobile,
isTablet: useDeviceData.isTablet,
isIos: useDeviceData.isIos,
isAndroid: useDeviceData.isAndroid,
isDeviceReady: false
})
const isPassedStoveGnb = ref(false)
const scrollFixedXValue = ref('0px')
const footerRef = ref<HTMLElement | null>(null)
const isLoading = ref<boolean>(true)
const isScrollLock = ref<boolean>(false)
const updateDeviceMode = () => {
device.value.mode = useDeviceData.isMobile || useDeviceData.isTablet ? 'mobile' : 'desktop'
device.value.browser = useDeviceData.isChrome
? 'chrome'
: useDeviceData.isCrawler
? 'crawler'
: useDeviceData.isEdge
? 'edge'
: useDeviceData.isFirefox
? 'firefox'
: useDeviceData.isSafari
? 'safari'
: null
device.value.isDesktop = useDeviceData.isDesktop
device.value.isMobile = useDeviceData.isMobile
device.value.isTablet = useDeviceData.isTablet
device.value.isIos = useDeviceData.isIos
device.value.isAndroid = useDeviceData.isAndroid
device.value.isDeviceReady = true
}
const updateIsPassedStoveGnb = () => {
isPassedStoveGnb.value = windowY.value >= stoveGnbHeight
if (isPassedStoveGnb.value) {
scrollFixedXValue.value = `-${windowX.value}px`
} else {
scrollFixedXValue.value = '0px'
}
}
const isLoadingComplete = () => {
isLoading.value = false
}
const scrollLock = () => {
isScrollLock.value = !isScrollLock.value
}
const addScrollLock = () => {
isScrollLock.value = true
}
const removeScrollLock = () => {
isScrollLock.value = false
}
return {
device,
windowWidth,
windowHeight,
windowX,
windowY,
isPassedStoveGnb,
scrollFixedXValue,
footerRef,
isLoading,
isScrollLock,
updateDeviceMode,
updateIsPassedStoveGnb,
isLoadingComplete,
scrollLock,
addScrollLock,
removeScrollLock
}
})

View File

@@ -1,5 +1,5 @@
import type { HTMLAttributes } from 'vue'
import type { StoveJsService } from '@/layers/types/Stove'
import type { StoveJsService } from '#layers/types/Stove'
export type ClassType = HTMLAttributes['class']
@@ -8,3 +8,58 @@ declare global {
stoveJsService?: StoveJsService
}
}
interface CommonRequestType {
baseApiUrl: string
gameId: string
}
interface CommonResponseType {
code?: number
message?: string
}
interface CommonPeriodType {
startDate?: string
endDate?: string
}
interface ParsedCustomLinkOptions {
tm: (key: string) => { txt: string }
query?: Record<string, any>
}
// 타입 정의
interface FooterMenuItem {
title: string
url: string
target?: string
active?: string
}
interface GameRatingInfo {
title: string
company_name: string
rating_grade: string
reg_no: string
prod_date: string
rating_class_no: string
rating_type: string
content_info: string
}
interface FooterData {
use_game_rating: boolean
game_rating_info: GameRatingInfo
use_dev_ci_url?: boolean
dev_ci_url?: string
dev_ci_img_path?: string
fund_display_yn?: string
fund_display_url?: string
}
interface DevCiConfig {
dev_ci_yn: boolean
dev_ci_img_path: string
}
export type { CommonRequestType, CommonResponseType, CommonPeriodType, ParsedCustomLinkOptions, FooterMenuItem, GameRatingInfo, FooterData, DevCiConfig }

View File

@@ -0,0 +1,100 @@
import type { CommonPeriodType } from '#layers/types/Common'
// [S] Type in czn_homepage_brand_siteConfig.json ----------------------------------------
interface GnbMenuType {
id: string
title: string
link: string
target: string
displayLocales?: Array<string>
}
interface GnbType extends GnbMenuType {
depth2List?: Array<GnbMenuType>
}
interface SnsType {
id: string
title: string
link: string
sub: string
key?: string
log?: object
}
interface LoreType {
loreNo: number
chapter: number // 1 : 프롤로그, 2 ~ : N장
title: string
description: string
}
interface CharacterCardType {
id: string
}
interface CharacterType {
id: string
cardList: Array<CharacterCardType>
}
interface FooterMenuType {
id: string
title: string
link: string
target: string
active: string
}
interface MediaType {
id: string
title: string
logCode?: string
}
interface MarketType {
id: string
code: string
link: string
}
// [E] Type in czn_homepage_brand_siteConfig.json ----------------------------------------
interface ReqGetDataization {
baseApiUrl: string
fileName?: string
}
interface DataizationType {
gnbList?: Array<GnbType>
mainVideo: CommonPeriodType
promotionList?: Array<PromotionPreregistType>
characterList?: Array<CharacterType>
loreList?: Array<LoreType>
footerMenuList?: Array<FooterMenuType>
mediaList?: Array<MediaType>
sectionList?: Array<string>
marketList?: Array<MarketType>
}
interface ResGetDataization {
code: number
message: string
value?: {
dataization?: DataizationType
}
}
export type {
// [S] Type in czn_homepage_brand_siteConfig.json ----------------------------------------
GnbType,
SnsType,
MediaType,
LoreType,
PromotionPreregistType,
FooterMenuType,
MarketType,
// [E] Type in czn_homepage_brand_siteConfig.json ----------------------------------------
DataizationType,
ReqGetDataization,
ResGetDataization
}

View File

@@ -0,0 +1,44 @@
import type { CommonRequestType, CommonResponseType } from './Common'
/*************************************************************************
* 게임 점검
************************************************************************/
interface ReqGameMaintenance extends CommonRequestType {
// Path Variables
category: string
service_id1: string
lang: string
}
interface Language {
lang: string
title: string
content: string
link: string
}
interface GameMaintenance {
maintenance_no: number // 점검 번호
category: string // 카테고리
service_id1: string // 서비스 ID1
service_id2: Array<string> // 서비스 ID2(String Array), service_id1 전체를 설정할 경우 ["*"]로 등록해야 함.
type: string // 점검타입(REGULAR / TEMPORARY / URGENT)
languages: Array<Language> // 다국어 리스트 정보
description: string // 설명
start_at: number // UTC기준 점검 시작일(milli-timestamp(13digit))
end_at: number // UTC기준 점검 종료일(milli-timestamp(13digit))
}
interface DtoGameMaintenance {
total_count: number
list: Array<GameMaintenance>
}
interface ResGameMaintenance extends CommonResponseType {
value?: DtoGameMaintenance
error?: string
}
// 게임 점검 데이터
interface GameMaintenanceData {
ts_start_date: number // 게임 점검 시작 타임스탬프
ts_end_date: number // 게임 점검 종료 타임스탬프
detail_link?: string // 게임 점검 공지 링크
}
export type { ReqGameMaintenance, ResGameMaintenance, GameMaintenanceData }

View File

@@ -0,0 +1,39 @@
import type { CommonRequestType, CommonResponseType } from './Common'
/*************************************************************************
* 웹 점검
************************************************************************/
interface WebInspectionData {
inspection_status: number // 점검 상태 (0: 정상, 1: 점검 중) (단순 운영툴 설정 점검 값)
start_date: string // 점검 시작 날짜 (문자열 형식)
end_date: string // 점검 종료 날짜 (문자열 형식)
ts_start_date: number // 점검 시작 타임스탬프
ts_end_date: number // 점검 종료 타임스탬프
back_ground_image_type?: string // 배경 이미지 타입 (0: 없음, 기타 값: 특정 타입)
back_ground_image_url?: string // 배경 이미지 URL
movie_yn?: string // 동영상 사용 여부 ("Y" 또는 "N")
movie_url?: string // 동영상 URL
inspection_title_type?: string // 점검 제목 타입
inspection_title1: string // 점검 제목 1
inspection_title2: string // 점검 제목 2
inspection_content?: string // 점검 내용
// Internal -----
ip_filter_use_yn?: string // IP 필터 사용 여부 ("Y" 또는 "N")
ip_filter_list?: string[] // 허용된 IP 목록
launching_status?: number // 런칭 여부 (0: 런칭 전, 1: 런칭 후)
}
interface ReqGetInspectionData extends CommonRequestType {
// do nothing
}
interface DtoGetInspectionData {
inspection_status?: number // 점검 여부 + 점검 시간 + 화이트 리스트 고려하여 계산된 결과
inspection?: WebInspectionData
}
interface ResGetInspectionData extends CommonResponseType {
value?: DtoGetInspectionData
}
export type { WebInspectionData, ReqGetInspectionData, ResGetInspectionData }

View File

@@ -4,6 +4,10 @@ export interface GameDataRequest {
lang_code: string
q?: string
qc?: string
baseApiUrl: string
gameId: string
gameDomain: string
langCode: string
}
// API 응답 데이터 타입

168
layers/utils/commonUtil.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { ParsedCustomLinkOptions } from '#layers/types/Common'
/**
* 페이지 - 유효성 체크
*
* @param {number} page - 페이지
* @param {number} totalPage - 총 페이지 수
*/
const checkPageValidation = (page: number, totalPage: number) => {
// 최소, 최대 범위 체크
if (page < 1) {
page = 1
} else if (page > totalPage) {
page = totalPage
}
return page
}
/**
* 파일 다운로드 함수
*
* @param {string} fileUrl - 다운로드할 파일의 URL
* @param {string} fileName - 저장할 파일 이름 (옵션)
*/
const csrDownloadFile = (fileUrl: string, fileName?: string) => {
const link = document.createElement('a')
link.href = fileUrl
// 파일 이름이 제공되면 다운로드 이름 설정
if (fileName) {
link.download = fileName
}
// 링크를 클릭하여 다운로드 트리거
document.body.appendChild(link)
link.click()
// DOM에서 링크 제거
document.body.removeChild(link)
}
/**
* 마케팅 코드 조회
*/
const csrGetMarketingCode = () => {
const route = useRoute()
const mcode = Number(`${route.query.mcode != null && route.query.mcode !== '' ? route.query.mcode : ''}`)
return isNaN(mcode) ? undefined : mcode
}
/**
* 외부 링크 이동 (새 창)
*
* @param {string} link - 이동할 외부 링크
*/
const csrGoExternalLink = (link: string = '') => {
window.open(link, '_blank')
}
/**
* QA용 국가 코드 조회
*/
const csrGetQc = () => {
const route = useRoute()
const qc = `${route.query.qc != null && route.query.qc !== '' ? route.query.qc : ''}`
return qc
}
/**
* 문자열이 숫자인지 확인
*
* @param {string} str - 확인할 문자열
*/
const isNumeric = (str: string): boolean => {
return /^-?\d+(\.\d+)?$/.test(str)
}
/**
* 가공된 링크 파싱
*
* @param {string} link - 원본 링크
* @param {Function} tm - i18n의 tm 함수 (예: (key) => ({ txt: string }))
* @param {any} query - 추가 쿼리 파라미터
*/
const getParsedCustomLink = (link: string, { tm, query = {} }: ParsedCustomLinkOptions) => {
const config = 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] : ''
})
}
// @m{key} 패턴 치환 (예: @m{Community_Channel_Key})
if (link.includes('@m')) {
result = result.replace(/@m\{(.*?)\}/g, (_, key) => {
// tm 함수로 변환하여 치환
return tm(key)?.txt ?? ''
})
}
// @q{key} 패턴 치환 (예: @q{ppid})
if (link.includes('@q')) {
result = result.replace(/@q\{(.*?)\}/g, (_, key) => {
let q = ''
if (query[key]) {
q += result.includes('?') ? '&' : '?'
q += `${key}=${query[key]}`
}
return q
})
}
return result
}
/**
* 쿠키 설정 - 만료기간 하루 단위 셋팅
*
* @param {string} name - 쿠키 이름
* @param {string} value - 쿠키 값
* @param {number} exp - 만료기간 (옵션)
*/
const setCookieForDay = (name: string, value: string, exp?: number) => {
const date = new Date()
if (!exp) {
exp = 1
}
date.setTime(date.getTime() + exp * 24 * 60 * 60 * 1000)
const setCookie = useCookie(name, {
expires: new Date(date),
path: '/'
})
setCookie.value = value
}
// 정적 파일인지 확인하는 함수
const isStaticFile = (path: string): boolean => {
return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|scss)$/i.test(path)
}
/**
* 기준값이 최솟값 ~ 최댓값에 속하는지 확인
*
* @param {number} ref - 기준값
* @param {number} min - 최솟값
* @param {number} max - 최댓값
*/
const isInRange = (ref: number, min: number, max: number): boolean => {
return ref >= min && ref <= max
}
export {
checkPageValidation,
csrDownloadFile,
csrGetMarketingCode,
csrGoExternalLink,
csrGetQc,
isNumeric,
getParsedCustomLink,
setCookieForDay,
isStaticFile,
isInRange
}

View File

@@ -20,7 +20,7 @@ import type { OperateComponents } from '#layers/types/api/resourcesData'
export const getLayoutType = (
pageData: PageDataValue | null
): 'default' | 'promotion' => {
return pageData?.page_type === 1 ? 'default' : 'promotion'
return pageData?.page_type === 2 ? 'promotion' : 'default'
}
/**
@@ -145,6 +145,52 @@ export const getImagePaths = (resourcesData: PageDataResourceGroup) => {
}
}
export const formatDateOffset = ({
ts,
lang,
useSeconds,
useTimezone
}: {
ts: number
lang: string
useSeconds?: boolean
useTimezone?: boolean
}) => {
const offset = { ko: 9, ja: 9, 'zh-tw': 8, en: 0 }[lang] || 0
const date = new Date(ts + offset * 3600000)
const pad = (n: number) => String(n).padStart(2, '0')
const year = date.getUTCFullYear()
const month = date.getUTCMonth() + 1
const day = date.getUTCDate()
const hours = date.getUTCHours()
const minutes = date.getUTCMinutes()
const seconds = date.getUTCSeconds()
if (lang === 'ko') {
let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`
format += useSeconds ? `:${pad(seconds)}` : ''
format += useTimezone ? ' (KST)' : ''
return `${format}`
} else if (lang === 'zh-tw') {
let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`
format += useSeconds ? `:${pad(seconds)}` : ''
format += useTimezone ? ` (UTC${offset > 0 ? '+' + offset : ''})` : ''
return `${format}`
} else if (lang === 'ja') {
let format = `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}`
format += useSeconds ? `:${pad(seconds)}` : ''
format += useTimezone ? ' (日本時間)' : ''
return `${format}`
} else {
//= en
let format = `${pad(month)}/${pad(day)}/${year} ${pad(hours)}:${pad(minutes)}`
format += useSeconds ? `:${pad(seconds)}` : ''
format += useTimezone ? ' (UTC)' : ''
return `${format}`
}
}
/**
* 컴포넌트 그룹의 첫 번째 데이터를 반환합니다.
* @param components props.components 또는 group 객체

View File

@@ -1,6 +1,6 @@
import { DEFAULT_LOCALE_CODE, DEFAULT_COVERAGES } from '@/i18n.config'
import { DEFAULT_LOCALE_CODE } from '../../i18n.config'
// 사용자 선호 언어 가져오기
// 사용자 선호 언어 조회
export const getPreferredLanguage = (acceptLanguageHeader = '') => {
const languages = acceptLanguageHeader
.split(',')
@@ -13,58 +13,18 @@ export const getPreferredLanguage = (acceptLanguageHeader = '') => {
return languages.length > 0 ? languages[0].code : null
}
export const getFinalLanguage = (path = '', defaultLocale: string, coverages: string[]) => {
// const nuxtApp = useNuxtApp()
let finalLocale = ''
let requestedLocale
let acceptLanguage: string
let defaultLang = 'en'
let defaultLangEn: string
if (defaultLocale) {
defaultLangEn = defaultLocale
} else {
defaultLangEn = 'en'
// 쿠키 파싱 유틸리티 함수
const parseCookies = (cookieHeader: string) => {
const cookies: Record<string, string> = {}
if (cookieHeader) {
cookieHeader.split(';').forEach((cookie) => {
const [name, value] = cookie.trim().split('=')
if (name && value) {
cookies[name] = decodeURIComponent(value)
}
requestedLocale = path?.split('/')[1]?.toLowerCase() ?? 'undefined'
if (import.meta.server) {
const headers = useRequestHeaders(['accept-language'])
acceptLanguage = headers['accept-language'] || defaultLangEn
defaultLang =
coverages.find((locale: string) => getPreferredLanguage(acceptLanguage)?.startsWith(locale)) || defaultLangEn
})
}
// const DEFAULT_COVERAGES = i18n.locales.map((locale) => locale.code)
const DEFAULT_COVERAGES = coverages
const requestedPage = path?.split('/')[2]?.toLowerCase() ?? undefined
const localeMap: Record<string, string> = {
'zh-tw': 'zh-TW',
'zh-cn': 'zh-CN'
}
if (localeMap[requestedLocale]) {
requestedLocale = localeMap[requestedLocale]
}
if (requestedLocale !== undefined && DEFAULT_COVERAGES.includes(requestedLocale)) {
finalLocale = requestedLocale
} else if (
requestedLocale === undefined ||
requestedLocale === '' ||
path !== '' ||
(requestedLocale !== undefined && !DEFAULT_COVERAGES.includes(requestedLocale) && requestedPage !== undefined)
) {
// 요청된 언어가 없을 때 or 잘못된 언어코드로 요청 시 브라우저 언어로 설정
finalLocale = defaultLang
} else {
// 그 외의 경우 기본 언어로 설정 (중국어 번체)
finalLocale = defaultLangEn
}
return finalLocale.toLowerCase()
return cookies
}
/**
@@ -73,34 +33,38 @@ export const getFinalLanguage = (path = '', defaultLocale: string, coverages: st
* @param {string} path - 현재 URL 경로
*/
export const csrGetFinalLocale = (path = '') => {
const config = useRuntimeConfig()
const baseDomain = `${config.public.baseDomain}`
let finalLocale = DEFAULT_LOCALE_CODE // 기본값 설정
const localeMap: Record<string, string> = {
'zh-tw': 'zh-TW',
'zh-cn': 'zh-CN'
}
// 1. URL 패스에 포함된 언어
if (path && path !== '' && path.split('/').length > 1) {
const pathLocal = path.split('/')[1]
// 쿼리스트링 제거한 순수 path 검사
if (path.includes('?')) {
path = path.split('?')[0]
}
const pathLocale = `${path.split('/')[1]}`.toLowerCase()
// URL 패스에 포함된 언어가 지원하는 언어인지 체크
if (pathLocal && pathLocal !== '' && DEFAULT_COVERAGES.includes(pathLocal)) {
finalLocale = pathLocal // .toLowerCase()
if (localeMap[pathLocal]) {
finalLocale = localeMap[pathLocal]
}
if (pathLocale && pathLocale !== '') {
finalLocale = pathLocale
return finalLocale
}
return finalLocale
}
// 2. 브라우저 언어
const browserLanguage = navigator.language || navigator.languages[0]
if (browserLanguage && browserLanguage !== '' && DEFAULT_COVERAGES.includes(browserLanguage)) {
finalLocale = browserLanguage // .toLowerCase()
if (localeMap[browserLanguage]) {
finalLocale = localeMap[browserLanguage]
// 2. LOCALE 쿠키 언어
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)) {
finalLocale = browserLanguage
return finalLocale
}
@@ -118,22 +82,31 @@ export const csrGetFinalLocale = (path = '') => {
*/
export const ssrGetFinalLocale = (path = '', headers: any) => {
let finalLocale = DEFAULT_LOCALE_CODE // 기본값 설정
try {
// 1. URL path에 포함된 언어 정보
if (path && path !== '' && path.split('/').length > 1) {
const pathLocale = path.split('/')[1]
// 쿼리스트링 제거한 순수 path 검사
if (path.includes('?')) {
path = path.split('?')[0]
}
const pathLocalee = `${path.split('/')[1]}`.toLowerCase()
// URL path에 포함된 언어 정보가 지원하는 언어인지 체크
if (pathLocale && pathLocale !== '' && DEFAULT_COVERAGES.includes(pathLocale)) {
finalLocale = pathLocale // .toLowerCase()
if (pathLocalee && pathLocalee !== '') {
finalLocale = pathLocalee
return finalLocale
}
}
// 2. 요청 헤더의 브라우저 언어 (accept-language)
// 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)) {
finalLocale = cookieLanguage
return finalLocale
}
// 3. 요청 헤더의 브라우저 언어 (accept-language)
if (headers && headers['accept-language']) {
const acceptLanguage = Array.isArray(headers['accept-language'])
? headers['accept-language'][0]
@@ -143,13 +116,11 @@ export const ssrGetFinalLocale = (path = '', headers: any) => {
const preferredLocale = getPreferredLanguage(acceptLanguage)
if (preferredLocale) {
// 선호 언어의 기본 코드와 일치하는 지원 로케일 찾기
const matchedLocale = DEFAULT_COVERAGES.find((locale: string) =>
const matchedLocale = coveragesLocales.find((locale: string) =>
preferredLocale.toLowerCase().startsWith(locale.toLowerCase())
)
if (matchedLocale) {
finalLocale = matchedLocale
// return matchedLocale.toLowerCase()
finalLocale = matchedLocale.toLowerCase()
return finalLocale
}
}
@@ -159,7 +130,6 @@ export const ssrGetFinalLocale = (path = '', headers: any) => {
// 3. 서비스 기본 언어
finalLocale = DEFAULT_LOCALE_CODE
} catch (e) {
console.error('[Exception] localeUtil.ssrGetFinalLocale: ', e)
finalLocale = DEFAULT_LOCALE_CODE
}
return finalLocale

View File

@@ -63,7 +63,7 @@ export default defineNuxtConfig({
payloadExtraction: false,
},
typescript: {
typeCheck: true,
typeCheck: false,
strict: false,
},
nitro: {

View File

@@ -29,6 +29,7 @@
"@nuxtjs/device": "^3.2.4",
"@nuxtjs/i18n": "^10.0.6",
"@pinia/nuxt": "^0.6.1",
"@seed-next/date": "^0.0.0",
"@splidejs/splide": "^4.1.4",
"@splidejs/vue-splide": "^0.6.12",
"@vueuse/core": "^13.6.0",
@@ -55,6 +56,7 @@
"eslint-plugin-nuxt": "^4.0.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.4.0",
"lru-cache": "^11.1.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",

36
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@pinia/nuxt':
specifier: ^0.6.1
version: 0.6.1(magicast@0.3.5)(typescript@5.9.2)(vue@3.5.21(typescript@5.9.2))
'@seed-next/date':
specifier: ^0.0.0
version: 0.0.0
'@splidejs/splide':
specifier: ^4.1.4
version: 4.1.4
@@ -96,6 +99,9 @@ importers:
eslint-plugin-vue:
specifier: ^10.4.0
version: 10.4.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.35.0(jiti@2.5.1)))
lru-cache:
specifier: ^11.1.0
version: 11.2.2
postcss:
specifier: ^8.5.6
version: 8.5.6
@@ -302,6 +308,12 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@date-fns/utc@2.1.1':
resolution: {integrity: sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA==}
'@emnapi/core@1.5.0':
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
@@ -1496,6 +1508,9 @@ packages:
cpu: [x64]
os: [win32]
'@seed-next/date@0.0.0':
resolution: {integrity: sha1-d6+dtjsFjxR4SGWSuBpJrxZCgwc=, tarball: https://git.sginfra.net/api/v4/projects/3708/packages/npm/@seed-next/date/-/@seed-next/date-0.0.0.tgz}
'@sindresorhus/is@7.0.2':
resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==}
engines: {node: '>=18'}
@@ -2288,6 +2303,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
db0@0.3.2:
resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==}
peerDependencies:
@@ -3246,6 +3264,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -5087,6 +5109,10 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.0
'@date-fns/tz@1.4.1': {}
'@date-fns/utc@2.1.1': {}
'@emnapi/core@1.5.0':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -6253,6 +6279,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.50.0':
optional: true
'@seed-next/date@0.0.0':
dependencies:
'@date-fns/tz': 1.4.1
'@date-fns/utc': 2.1.1
date-fns: 4.1.0
'@sindresorhus/is@7.0.2': {}
'@sindresorhus/merge-streams@2.3.0': {}
@@ -7155,6 +7187,8 @@ snapshots:
csstype@3.1.3: {}
date-fns@4.1.0: {}
db0@0.3.2: {}
de-indent@1.0.2: {}
@@ -8147,6 +8181,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

286
temp/middleware.ts Normal file
View File

@@ -0,0 +1,286 @@
import { LRUCache } from 'lru-cache'
// import { DEFAULT_LOCALE_COVERAGES } from '@/i18n.config'
import { getTrueClientIp } from '#layers/utils/apiUtil'
import { ssrGetFinalLocale } from '#layers/utils/localeUtil'
import type { ResGetInspectionData, WebInspectionData } from '#layers/types/InspectionType'
import { isStaticFile } from '#layers/utils/commonUtil'
console.log("🚀 ~ setCacheHeaders ~ event.node.res.setHeader:")
/**
* 캐시 제어 헤더를 설정하는 공통 함수
*
* @param event - 이벤트 객체
* @param cacheMode - 캐시 모드 설정 ('no-cache', 'short', 'medium', 'default')
* @param customMaxAge - 커스텀 max-age 값 (초 단위)
*/
function setCacheHeaders(
event: { node: { res: { setHeader: (name: string, value: string) => void } } },
cacheMode: 'no-cache' | 'short' | 'medium' | 'default',
customMaxAge?: number
): void {
// 원래 setHeader 함수 참조 저장
const originalSetHeader = event.node.res.setHeader
// Cache-Control 헤더 설정값 결정
let cacheControl: string
switch (cacheMode) {
case 'no-cache':
cacheControl = 'no-cache, no-store, must-revalidate'
// no-cache 모드일 때는 추가 헤더도 설정
event.node.res.setHeader('Pragma', 'no-cache')
event.node.res.setHeader('Expires', '0')
break
case 'short':
cacheControl = `public, max-age=${customMaxAge || 10}`
break
case 'medium':
cacheControl = `public, max-age=${customMaxAge || 15}`
break
case 'default':
default:
cacheControl = `public, max-age=${customMaxAge || 60}`
break
}
// Cache-Control 헤더를 강제로 설정하기 위해 setHeader 메소드 오버라이드
event.node.res.setHeader = function (name: string, value: string) {
if (name.toLowerCase() === 'cache-control') {
return originalSetHeader.call(this, name, cacheControl)
}
return originalSetHeader.call(this, name, value)
}
// 바로 캐시 제어 헤더 적용
}
const cache = new LRUCache({
max: 100, // 캐시에 저장할 최대 항목 수
ttl: 1000 * 30 // 30초 동안 캐시 유지
})
/**
* 최종 언어 쿠키 세팅
*
* @param event - 이벤트 객체
* @param finalLocale - 최종 언어
* @param baseDomain - 기본 도메인
*/
function setFinalLocaleCookie(event: any, finalLocale: string, baseDomain: string) {
setCookie(event, 'LOCALE', finalLocale.toUpperCase(), {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365 // 1년 (초 단위)
})
}
/**
* Locale Middleware 역할 함수
*
* @param event - 이벤트 객체
* @param finalLocale - 최종 언어
*/
function fnLocaleMiddleware(event: any, finalLocale: string) {
const path = event?.node.req.url || ''
let arrPath = []
let queryString = ''
if (path.includes('?')) {
// 쿼리스트링 포함 시 순수 경로만 추출
arrPath = path.split('?')[0].split('/')
queryString = path.split('?')[1]
} else {
arrPath = path.split('/')
queryString = ''
}
// 최종 언어 세팅된 경로 생성
const pathLocale = arrPath.length > 1 ? arrPath[1] : ''
// URL에서 현재 언어와 최종 언어가 다르면 리다이렉트
if (pathLocale !== finalLocale) {
let newLocalePath = ''
if (pathLocale === '') {
newLocalePath = `/${finalLocale}`
} else {
arrPath[1] = finalLocale
newLocalePath = arrPath.join('/')
}
if (queryString !== '') {
newLocalePath += `?${queryString}`
}
event.node.res.statusCode = 302
event.node.res.setHeader('Location', newLocalePath)
event.node.res.end()
}
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const runType = `${config.public.runType}`
const iBaseApiUrl = `${config.public.stoveApiUrlServer}`
const gameId = `${event.context.gameData?.game_id}`
const baseDomain = `${config.public.baseDomain}`
if (['local', 'local-gate8', 'dev'].includes(runType)) {
// Sandbox 이상 환경에서만 동작 및 확인 가능 (local, dev는 통과 처리)
try {
// 언어 코드 추출
const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
setFinalLocaleCookie(event, finalLocale, baseDomain)
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
} catch (e) {
console.error('[Exception] /server/middleware/middleware-global: ', e)
}
} else {
// -------------------------------------------------------------------------------
// [Inspection Middleware]
// -------------------------------------------------------------------------------
const fullPath = event.path
// 1-1. 정적 파일 패스
if (isStaticFile(event.path)) {
return
}
// 1-2. /inspection 패스
if (fullPath.includes('/inspection')) {
// 리턴 되기 전 언어 쿠키 세팅
const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
setFinalLocaleCookie(event, finalLocale, baseDomain)
return
}
// 1-3. 특정 경로 패스 (API, 리소스)
if (
fullPath.startsWith('/api/') ||
fullPath.startsWith('/_nuxt/') ||
fullPath.includes('/assets/') ||
fullPath.includes('favicon')
) {
return
}
// 캐시 키 생성
const cacheKey = 'inspection'
try {
// 2. 언어 코드 추출
const finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
setFinalLocaleCookie(event, finalLocale, baseDomain)
// 초기화
let inspectionData
// 3. 캐시된 데이터가 없거나 만료되었을 때만 API 호출
if (cache.has(cacheKey)) {
inspectionData = cache.get(cacheKey) as WebInspectionData
} else {
const apiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${gameId}`
// 직접 $fetch 사용 (composable 사용하지 않음)
const response = await $fetch<ResGetInspectionData>(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
inspectionData = response?.value?.inspection as WebInspectionData
console.log("🚀 00000 inspectionData:", inspectionData)
cache.set(cacheKey, 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)
// 5. 점검 상태별 캐시 설정
if (inspectionData?.inspection_status === 1 && currentTime >= tsStartDate && currentTime <= tsEndDate) {
/**
* 점검 중인 경우
* - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인
* - 점검 URL 경로가 아닐 경우 no-cache 설정
* - 화이트 리스트 체크
*/
// 점검 url path 가 아닐 경우, 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)
// 허용된 IP 목록 확인
if (!inspectionData?.ip_filter_list?.includes(clientIP)) {
// 허용되지 않은 IP인 경우 점검 페이지로 이동
event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath)
event.node.res.end()
} else {
// 화이트 리스트인 경우
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
} else {
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')
}
}
}
// -------------------------------------------------------------------------------
// [Locale Middleware]
// -------------------------------------------------------------------------------
fnLocaleMiddleware(event, finalLocale)
}
// 정상 접속 허용
} catch (e) {
console.error('[Exception] /server/middleware/middleware-02-global: ', e)
}
}
})

View File

@@ -14,6 +14,6 @@
"types/**/*",
"layers/**/*",
"app/**/*"
],
, "temp/inspection.ts", "temp/middleware.ts" ],
"exclude": [".nuxt/types/**/*", "node_modules"]
}