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

This commit is contained in:
“hyeonggkim”
2025-10-16 19:04:36 +09:00
37 changed files with 759 additions and 350 deletions

14
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"css.validate": false,
"tailwindCSS.includeLanguages": {
"vue": "html",
"css": "css"
},
"tailwindCSS.experimental.classRegex": [
["class[:]\\s*['\"`]([^'\"`]*)['\"`]", "([^'\"`]*)"],
["@apply\\s+([^;]+)", "([^;]+)"]
],
"files.associations": {
"*.css": "tailwindcss"
}
}

View File

@@ -28,7 +28,7 @@ const getGameDataFromServer = (): GameDataValue | null => {
const setupAllMetaData = (data: GameDataValue) => {
const meta = data.meta_tag
const faviconPath = data.favicon_path
const theme = data.design_theme === 1 ? 'dark' : 'light'
const theme = data.gnb.theme_type || 'dark'
// 파비콘 링크 생성
const faviconLinks = [

View File

@@ -32,11 +32,11 @@ export default createConfigForNuxt({
'vue/no-multiple-template-root': 'off',
'vue/no-required-prop-with-default': 'off',
'vue/require-directive': 'off',
'vue/html-self-closing': 'off',
// 일반 규칙 (품질/버그 탐지)
'no-console': 'warn',
'no-debugger': 'error',
'no-unused-vars': 'off',
'prefer-const': 'warn',
'no-var': 'error',
},

View File

@@ -4,22 +4,22 @@
import commonData from '../../layers/assets/data/common.json'
export default defineI18nLocale(async (locale: string) => {
const config = useRuntimeConfig()
const baseType = config.public.baseType
const translationItems = config.public.translationItems
const runtimeConfig = useRuntimeConfig()
const baseType = runtimeConfig.public.baseType
const translationItems = runtimeConfig.public.translationItems
const translationItemsArr = translationItems.split(',')
const staticUrl = config.public.staticUrl
const staticUrl = runtimeConfig.public.staticUrl
const translationApi = translationItemsArr.map((item: string): string => {
return `${staticUrl}/${baseType}/tmp/${item}.json`
})
// API 데이터 가져오기
const fetchDataPromises = translationApi.map((apiUrl) => {
const fetchDataPromises = translationApi.map(apiUrl => {
return useFetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
'Content-Type': 'application/json;charset=UTF-8',
},
})
})
@@ -27,7 +27,7 @@ export default defineI18nLocale(async (locale: string) => {
const fetchResults = await Promise.all(fetchDataPromises)
// 각 결과에서 locale에 맞는 데이터를 추출
const apiData = fetchResults.map((result) => {
const apiData = fetchResults.map(result => {
return result.data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
})
@@ -38,7 +38,7 @@ export default defineI18nLocale(async (locale: string) => {
// common.json에서 해당 locale의 데이터를 가져와서 병합
const commonLocaleData = commonData[locale] || {}
// API 데이터와 common.json 데이터를 병합 (common.json이 우선순위)
const finalResult = { ...mergedResult, ...commonLocaleData }

View File

@@ -4,22 +4,22 @@
import commonData from '../../layers/assets/data/common.json'
export default defineI18nLocale(async (locale: string) => {
const config = useRuntimeConfig()
const baseType = config.public.baseType
const translationItems = config.public.translationItems
const runtimeConfig = useRuntimeConfig()
const baseType = runtimeConfig.public.baseType
const translationItems = runtimeConfig.public.translationItems
const translationItemsArr = translationItems.split(',')
const staticUrl = config.public.staticUrl
const staticUrl = runtimeConfig.public.staticUrl
const translationApi = translationItemsArr.map((item: string): string => {
return `${staticUrl}/${baseType}/tmp/${item}.json`
})
// API 데이터 가져오기
const fetchDataPromises = translationApi.map((apiUrl) => {
const fetchDataPromises = translationApi.map(apiUrl => {
return useFetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
'Content-Type': 'application/json;charset=UTF-8',
},
})
})
@@ -27,7 +27,7 @@ export default defineI18nLocale(async (locale: string) => {
const fetchResults = await Promise.all(fetchDataPromises)
// 각 결과에서 locale에 맞는 데이터를 추출
const apiData = fetchResults.map((result) => {
const apiData = fetchResults.map(result => {
return result.data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
})
@@ -38,7 +38,7 @@ export default defineI18nLocale(async (locale: string) => {
// common.json에서 해당 locale의 데이터를 가져와서 병합
const commonLocaleData = commonData[locale] || {}
// API 데이터와 common.json 데이터를 병합 (common.json이 우선순위)
const finalResult = { ...mergedResult, ...commonLocaleData }

View File

@@ -4,22 +4,22 @@
import commonData from '../../layers/assets/data/common.json'
export default defineI18nLocale(async (locale: string) => {
const config = useRuntimeConfig()
const baseType = config.public.baseType
const translationItems = config.public.translationItems
const runtimeConfig = useRuntimeConfig()
const baseType = runtimeConfig.public.baseType
const translationItems = runtimeConfig.public.translationItems
const translationItemsArr = translationItems.split(',')
const staticUrl = config.public.staticUrl
const staticUrl = runtimeConfig.public.staticUrl
const translationApi = translationItemsArr.map((item: string): string => {
return `${staticUrl}/${baseType}/tmp/${item}.json`
})
// API 데이터 가져오기
const fetchDataPromises = translationApi.map((apiUrl) => {
const fetchDataPromises = translationApi.map(apiUrl => {
return useFetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
'Content-Type': 'application/json;charset=UTF-8',
},
})
})
@@ -27,7 +27,7 @@ export default defineI18nLocale(async (locale: string) => {
const fetchResults = await Promise.all(fetchDataPromises)
// 각 결과에서 locale에 맞는 데이터를 추출
const apiData = fetchResults.map((result) => {
const apiData = fetchResults.map(result => {
return result.data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
})
@@ -38,7 +38,7 @@ export default defineI18nLocale(async (locale: string) => {
// common.json에서 해당 locale의 데이터를 가져와서 병합
const commonLocaleData = commonData[locale] || {}
// API 데이터와 common.json 데이터를 병합 (common.json이 우선순위)
const finalResult = { ...mergedResult, ...commonLocaleData }

View File

@@ -4,22 +4,22 @@
import commonData from '../../layers/assets/data/common.json'
export default defineI18nLocale(async (locale: string) => {
const config = useRuntimeConfig()
const baseType = config.public.baseType
const translationItems = config.public.translationItems
const runtimeConfig = useRuntimeConfig()
const baseType = runtimeConfig.public.baseType
const translationItems = runtimeConfig.public.translationItems
const translationItemsArr = translationItems.split(',')
const staticUrl = config.public.staticUrl
const staticUrl = runtimeConfig.public.staticUrl
const translationApi = translationItemsArr.map((item: string): string => {
return `${staticUrl}/${baseType}/tmp/${item}.json`
})
// API 데이터 가져오기
const fetchDataPromises = translationApi.map((apiUrl) => {
const fetchDataPromises = translationApi.map(apiUrl => {
return useFetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
'Content-Type': 'application/json;charset=UTF-8',
},
})
})
@@ -27,7 +27,7 @@ export default defineI18nLocale(async (locale: string) => {
const fetchResults = await Promise.all(fetchDataPromises)
// 각 결과에서 locale에 맞는 데이터를 추출
const apiData = fetchResults.map((result) => {
const apiData = fetchResults.map(result => {
return result.data.value?.[locale] || {} // locale에 맞는 데이터가 없으면 빈 객체 반환
})
@@ -38,7 +38,7 @@ export default defineI18nLocale(async (locale: string) => {
// common.json에서 해당 locale의 데이터를 가져와서 병합
const commonLocaleData = commonData[locale] || {}
// API 데이터와 common.json 데이터를 병합 (common.json이 우선순위)
const finalResult = { ...mergedResult, ...commonLocaleData }

View File

@@ -11,4 +11,11 @@
a {
outline: none;
}
/* 라이트 테마 색상 */
[data-theme='light'] {
body {
background-color: #fff;
}
}
}

View File

@@ -1,19 +1,4 @@
/* CSS 변수 정의 - @layer 밖에 위치 */
:root {
--foreground: #ffffff;
--foreground-10: #ffffff;
--foreground-reversal: #1f1f1f;
--foreground-reversal-4: rgba(0, 0, 0, 0.04);
--foreground-reversal-6: rgba(0, 0, 0, 0.06);
--foreground-reversal-8: rgba(0, 0, 0, 0.08);
--foreground-reversal-10: rgba(0, 0, 0, 0.1);
--foreground-reversal-15: rgba(0, 0, 0, 0.15);
--foreground-reversal-30: #ebebeb; /* gray-80 */
}
/* 다크 테마 색상 */
[data-theme='dark'] {
--foreground: #191919;
--foreground-10: #292929;
@@ -23,7 +8,26 @@
--foreground-reversal-8: rgba(255, 255, 255, 0.08);
--foreground-reversal-10: rgba(255, 255, 255, 0.1);
--foreground-reversal-15: rgba(255, 255, 255, 0.15);
--foreground-reversal-30: #404040; /* gray-750 */
--foreground-gray-750: #404040;
--foreground-gray-500: #7f7f7f;
}
/* 라이트 테마 색상 */
[data-theme='light'] {
--foreground: #ffffff;
--foreground-10: #ffffff;
--foreground-reversal: #1f1f1f;
--foreground-reversal-4: rgba(0, 0, 0, 0.04);
--foreground-reversal-6: rgba(0, 0, 0, 0.06);
--foreground-reversal-8: rgba(0, 0, 0, 0.08);
--foreground-reversal-10: rgba(0, 0, 0, 0.1);
--foreground-reversal-15: rgba(0, 0, 0, 0.15);
--foreground-reversal-50: rgba(0, 0, 0, 0.5); /* #7F7F7F */
--foreground-gray-750: #ebebeb; /* gray-80 */
--foreground-gray-500: #999999; /* gray-400 */
}
/* 커스텀 컴포넌트 스타일 */

View File

@@ -7,7 +7,7 @@ interface Props {
withDefaults(defineProps<Props>(), {
size: 12,
color: '#7F7F7F',
color: 'var(--foreground-gray-500)',
className: '',
})
</script>
@@ -23,7 +23,7 @@ withDefaults(defineProps<Props>(), {
>
<path
d="M5.29499 7.715L2.39999 4.875C2.07499 4.555 2.29999 4 2.75999 4L9.23499 4C9.69499 4 9.91999 4.555 9.59499 4.875L6.69999 7.715C6.30999 8.095 5.68999 8.095 5.29999 7.715H5.29499Z"
fill="#7F7F7F"
:fill="color"
/>
</svg>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 32 33"
fill="none"
:class="className"
>
<path
d="M26.2768 8.10939C26.7975 7.5887 26.7975 6.74448 26.2768 6.22378C25.7561 5.70308 24.9119 5.70308 24.3912 6.22378L16.0007 14.6143L7.61013 6.22378C7.08943 5.70308 6.24521 5.70308 5.72451 6.22378C5.20381 6.74448 5.20381 7.5887 5.72451 8.10939L14.115 16.4999L5.72451 24.8904C5.20381 25.4111 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3855L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4111 26.2768 24.8904L17.8863 16.4999L26.2768 8.10939Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 32 32"
fill="none"
:class="className"
>
<path
d="M26.2768 8.10947C26.7975 7.58877 26.7975 6.74455 26.2768 6.22385C25.7561 5.70315 24.9119 5.70315 24.3912 6.22385L16.0007 14.6144L7.61013 6.22385C7.08943 5.70315 6.24521 5.70315 5.72451 6.22385C5.20381 6.74455 5.20381 7.58877 5.72451 8.10947L14.115 16.5L5.72451 24.8905C5.20381 25.4112 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3856L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4112 26.2768 24.8905L17.8863 16.5L26.2768 8.10947Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 24,
color: 'var(--foreground-reversal)',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:class="className"
>
<path
d="M20 16C20.7594 16 21.375 16.6156 21.375 17.375C21.375 18.1344 20.7594 18.75 20 18.75H4C3.24061 18.75 2.625 18.1344 2.625 17.375C2.625 16.6156 3.24061 16 4 16H20ZM20 10.5C20.7594 10.5 21.375 11.1156 21.375 11.875C21.375 12.6344 20.7594 13.25 20 13.25H4C3.24061 13.25 2.625 12.6344 2.625 11.875C2.625 11.1156 3.24061 10.5 4 10.5H20ZM20 5C20.7594 5 21.375 5.61561 21.375 6.375C21.375 7.13439 20.7594 7.75 20 7.75H4C3.24061 7.75 2.625 7.13439 2.625 6.375C2.625 5.61561 3.24061 5 4 5H20Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 16,
color: 'var(--foreground-reversal)',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="className"
>
<path
d="M15.7071 1.70711C16.0976 1.31658 16.0976 0.683417 15.7071 0.292893C15.3166 -0.0976311 14.6834 -0.0976311 14.2929 0.292893L8 6.58579L1.70711 0.292893C1.31658 -0.0976311 0.683417 -0.0976311 0.292894 0.292893C-0.0976304 0.683417 -0.0976304 1.31658 0.292894 1.70711L6.58579 8L0.292893 14.2929C-0.0976311 14.6834 -0.0976311 15.3166 0.292893 15.7071C0.683417 16.0976 1.31658 16.0976 1.70711 15.7071L8 9.41421L14.2929 15.7071C14.6834 16.0976 15.3166 16.0976 15.7071 15.7071C16.0976 15.3166 16.0976 14.6834 15.7071 14.2929L9.41421 8L15.7071 1.70711Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 24,
color: 'var(--foreground-reversal)',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:class="className"
>
<path
d="M4.5 10.5C3.675 10.5 3 11.175 3 12C3 12.825 3.675 13.5 4.5 13.5C5.325 13.5 6 12.825 6 12C6 11.175 5.325 10.5 4.5 10.5ZM19.5 10.5C18.675 10.5 18 11.175 18 12C18 12.825 18.675 13.5 19.5 13.5C20.325 13.5 21 12.825 21 12C21 11.175 20.325 10.5 19.5 10.5ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -7,7 +7,7 @@ interface Props {
withDefaults(defineProps<Props>(), {
size: 16,
color: '#B2B2B2',
color: 'var(--foreground-gray-500)',
className: '',
})
</script>
@@ -18,16 +18,16 @@ withDefaults(defineProps<Props>(), {
:width="size"
:height="size"
viewBox="0 0 16 16"
:fill="color"
fill="none"
:class="className"
>
<path
d="M3.63636 3.33333C3.469 3.33333 3.33333 3.469 3.33333 3.63636L3.33333 12.3636C3.33333 12.531 3.469 12.6667 3.63636 12.6667H12.3636C12.531 12.6667 12.6667 12.531 12.6667 12.3636V9.93939C12.6667 9.5712 12.9651 9.27273 13.3333 9.27273C13.7015 9.27273 14 9.5712 14 9.93939V12.3636C14 13.2674 13.2674 14 12.3636 14H3.63636C2.73262 14 2 13.2674 2 12.3636L2 3.63636C2 2.73263 2.73262 2 3.63636 2L6.06061 2C6.4288 2 6.72727 2.29848 6.72727 2.66667C6.72727 3.03486 6.4288 3.33333 6.06061 3.33333H3.63636Z"
fill="#B2B2B2"
:fill="color"
/>
<path
d="M12.6667 4.27614V6.54545C12.6667 6.91364 12.9651 7.21212 13.3333 7.21212C13.7015 7.21212 14 6.91364 14 6.54545V2.66667C14 2.29848 13.7015 2 13.3333 2L9.45455 2C9.08636 2 8.78788 2.29848 8.78788 2.66667C8.78788 3.03486 9.08636 3.33333 9.45455 3.33333L11.7239 3.33333L7.28616 7.77103C7.02581 8.03138 7.02581 8.45349 7.28616 8.71384C7.54651 8.97419 7.96862 8.97419 8.22897 8.71384L12.6667 4.27614Z"
fill="#B2B2B2"
:fill="color"
/>
</svg>
</template>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ClassType } from '#layers/types/Common'
interface Props {
to: string
target?: string
class?: string
class?: ClassType
}
const props = withDefaults(defineProps<Props>(), {

View File

@@ -27,6 +27,7 @@ const colorCode = computed(() => {
})
const currentImageSrc = computed(() => {
if (!imageSrc.value) return ''
return breakpoints.value.isMobile
? imageSrc.value.mobileSrc || ''
: imageSrc.value.pcSrc || ''
@@ -36,25 +37,20 @@ const currentImageSrc = computed(() => {
const sanitizedContent = computed(() => {
return displayText.value?.replace(/\n/g, '<br/>') || ''
})
// 이미지가 있는지 확인
const hasImage = computed(() => {
return imageSrc.value && (imageSrc.value.mobileSrc || imageSrc.value.pcSrc)
})
</script>
<template>
<!-- 이미지 -->
<img
v-if="hasImage && currentImageSrc"
v-if="isTypeImage(resourcesData?.resource_type) && currentImageSrc"
:src="currentImageSrc"
:alt="alt || displayText"
:class="`w-full h-full object-${objectFit}`"
loading="lazy"
/>
<!-- 텍스트 -->
<span
v-else-if="displayText"
v-else-if="isTypeText(resourcesData?.resource_type)"
v-dompurify-html="sanitizedContent"
:style="{ color: getColorCode({ colorName, colorCode }) }"
class="block"

View File

@@ -22,13 +22,13 @@
@click.stop
>
<!-- 헤더 -->
<div class="flex justify-end">
<div class="flex justify-end mb-3 md:mb-4">
<button
class="p-1 text-white rounded-full transition-colors"
class="text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<AtomsIconsClose />
<AtomsIconsCloseLine />
</button>
</div>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import { Splide, SplideSlide } from '@splidejs/vue-splide'
import { getFirstGroup, isTypeVideo } from '#layers/utils/dataUtil'
import { getMediaSrc, getYouTubeEmbedUrl } from '#layers/utils/youtube'
import type { Splide as SplideType, Options } from '@splidejs/splide'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
import type {
PageDataResourceGroups,
PageDataResourceGroup,
} from '#layers/types/api/pageData'
interface Props {
slideData: { media: any; set_order: number }[]
slideData: { media: PageDataResourceGroups; set_order: number }[]
videoPlay?: PageDataResourceGroup
arrows?: boolean
pagination?: boolean
@@ -46,10 +51,27 @@ const thumbOptions = computed<Options>(() => ({
},
}))
const isPassVideo = (groups: PageDataResourceGroups, index: number) => {
const firstGroup = getFirstGroup(groups)
return (
firstGroup &&
isTypeVideo(firstGroup?.resource_type) &&
index !== playingSlideIndex.value
)
}
const handleVideoClick = (index: number) => {
playingSlideIndex.value = index
}
const getYouTubeEmbedUrlFromMedia = (
resourceGroups: PageDataResourceGroup[]
) => {
const resourceGroup = getFirstGroup(resourceGroups)
const mediaSrc = getMediaSrc(resourceGroup)
return mediaSrc ? getYouTubeEmbedUrl(mediaSrc, true) : ''
}
let mainInst: SplideType | null = null
let thumbsInst: SplideType | null = null
@@ -87,16 +109,14 @@ onBeforeUnmount(() => {
:class="{ 'opacity-0': playingSlideIndex === index }"
/>
<AtomsButtonPlay
v-if="
getMediaType(item.media) === 'video' && playingSlideIndex !== index
"
v-if="isPassVideo(item.media, index)"
:resources-data="videoPlay"
class="btn-play"
@click="handleVideoClick(index)"
/>
<iframe
v-if="playingSlideIndex === index"
:src="getYouTubeEmbedUrl(getMediaText(item.media), true)"
:src="getYouTubeEmbedUrlFromMedia(item.media)"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import { useScrollStore } from '#layers/stores/useScrollStore'
import { useWindowScroll, onClickOutside, useWindowSize } from '@vueuse/core'
import { onClickOutside, useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type {
GameDataValue,
@@ -10,18 +10,18 @@ import type {
} from '#layers/types/api/gameData'
const route = useRoute()
const { y: windowY } = useWindowScroll()
const { width } = useWindowSize()
const gameDataStore = useGameDataStore()
const scrollStore = useScrollStore()
const _breakpoints = useResponsiveBreakpointsReliable()
const { isPassedStoveGnb } = storeToRefs(scrollStore)
const { isPassedStoveGnb, scrollGnbPosition } = storeToRefs(scrollStore)
const gameData = gameDataStore.gameData as GameDataValue
const gnbList = (gameData?.gnb?.menus ?? {}) as GameDataMenuChildren
const isMenuOpen = ref(false)
const navAreaRef = ref<HTMLElement>()
const startRef = ref<HTMLElement>()
const gameData = gameDataStore.gameData as GameDataValue
const gnbData = gameData?.gnb
const isMenuOpen = ref(false)
const navWidth = ref(0)
const startWidth = ref(0)
const officialItemWidths = ref<number[]>([])
@@ -34,15 +34,6 @@ const pathMatches = (base: string, current: string) => {
return current === base || current.startsWith(base + '/')
}
/** header overlay 높이 계산 (re-compute 최소화) */
const scrollPositionHeight = computed(() => {
const gnbHeight = scrollStore.stoveGnbHeight
const y = windowY.value
if (y === 0) return `${gnbHeight}px`
if (y >= gnbHeight) return '0px'
return `${gnbHeight - y}`
})
/** 자식 중 활성 링크 존재 여부 */
const hasActiveChild = (children?: GameDataMenuChildren) => {
const cur = currentPath.value
@@ -71,7 +62,8 @@ const calculateNavWidth = () => {
if (!navAreaRef.value) return 0
const navAreaWidth = navAreaRef.value.offsetWidth
return navAreaWidth
const moreWidth = 72 // 더보기 버튼 넓이 + 마진
return navAreaWidth + moreWidth
}
// startRef의 넓이를 구하는 함수
@@ -79,10 +71,11 @@ const calculateStartWidth = () => {
if (!startRef.value) return 0
const startWidth = startRef.value.offsetWidth
return startWidth + 40
const headerRightPadding = 40 // 헤더 오른쪽 마진
return startWidth + headerRightPadding
}
// official 자식들의 넓이를 구하는 함수 (뒤에서부터 순서대로)
// official 자식들의 넓이를 구하는 함수
const calculateOfficialItemWidths = () => {
if (!navAreaRef.value) return
@@ -103,8 +96,6 @@ const calculateOfficialItemWidths = () => {
calculateOverflow()
}
console.log(0, Object.keys(gnbList).length)
// 오버플로우 계산 함수
const calculateOverflow = () => {
if (!navAreaRef.value) return
@@ -112,18 +103,9 @@ const calculateOverflow = () => {
const totalNavWidth = navWidth.value + startWidth.value
const screenWidth = width.value
console.log('calculateOverflow called:', {
screenWidth,
totalNavWidth,
navWidth: navWidth.value,
startWidth: startWidth.value,
officialItemWidths: officialItemWidths.value,
})
// 모바일(1024px 미만)에서는 overflowNam을 0으로 설정
if (screenWidth < 1024) {
overflowNam.value = 0
console.log('Mobile view - overflowNam set to 0')
return
}
@@ -132,30 +114,30 @@ const calculateOverflow = () => {
let removedCount = 0
let currentTotal = totalNavWidth
// officialItemWidths를 하나씩 빼면서 해상도보다 작아지는지 확인
for (let i = 0; i < officialItemWidths.value.length; i++) {
currentTotal -= officialItemWidths.value[i]
removedCount++
// 해상도보다 작아지면 중단
if (currentTotal <= screenWidth) {
break
}
}
overflowNam.value = removedCount
console.log('Overflow calculated:', overflowNam.value)
} else {
overflowNam.value = 0
console.log('No overflow needed, setting to 0')
}
}
// 컴포넌트 마운트 후 한 번만 계산
onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
// 화면 크기 변경 시 오버플로우 재계산
watch(width, () => {
calculateOverflow()
})
onMounted(() => {
// 초기화
overflowNam.value = 0
console.log('onMounted - overflowNam 초기화:', overflowNam.value)
nextTick(() => {
if (navAreaRef.value && startRef.value) {
@@ -165,14 +147,6 @@ onMounted(() => {
}
})
})
// 화면 크기 변경 시 오버플로우 재계산
const { width } = useWindowSize()
watch(width, () => {
calculateOverflow()
})
onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
</script>
<template>
@@ -182,47 +156,52 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
<div class="game-wrapper" :class="{ 'is-fixed': isPassedStoveGnb }">
<AtomsLocaleLink to="/brand" class="mx-auto md:hidden">
<img
:src="gameData?.gnb?.bi_path"
:src="gnbData?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
<button class="btn-open" @click="handleMenuOpen">
<AtomsIconsMenuBoldLine class="mx-auto" />
<span class="sr-only">menu open</span>
</button>
<div
:class="['gnb-game', { 'is-open': isMenuOpen }]"
:style="{ '--scroll-position': scrollPositionHeight }"
:style="{ '--scroll-position': scrollGnbPosition + 'px' }"
>
<div ref="navAreaRef" class="nav-area">
<div class="nav-logo">
<AtomsLocaleLink to="/brand">
<img
:src="gameData?.gnb?.bi_path"
:src="gnbData?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
</div>
<nav class="nav-list">
<div v-if="gnbList" class="official">
<div v-if="gnbData?.menus" class="official">
<div
v-for="(gnbItem, key) in gnbList"
v-for="(gnbItem, key) in gnbData?.menus"
:key="key"
class="nav-item"
:class="{
'is-hidden':
overflowNam > 0 &&
Number(key) >= Object.keys(gnbList).length - overflowNam,
Number(key) >=
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`nav-1depth ${isNavItemActive(gnbItem) ? 'active' : ''}`"
:class="['nav-1depth', { active: isNavItemActive(gnbItem) }]"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsArrowDown v-if="gnbItem.children" />
<AtomsIconsArrowDownFill
v-if="gnbItem.children"
class="hidden md:block"
/>
</BlocksHybridLink>
<div v-if="gnbItem.children" class="nav-2depth">
<ul>
@@ -235,7 +214,7 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
:target="child.link_target"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsLinkOut
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
@@ -244,44 +223,47 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
</div>
</div>
</div>
<div v-if="gnbList && overflowNam > 0" class="more">
<div v-if="gnbData?.menus && overflowNam > 0" class="more">
<button class="btn-more">
<AtomsIconsOptionHorizontalFill class="mx-auto" />
<span class="sr-only">more</span>
</button>
<div class="more-list">
<div
v-for="(gnbItem, key) in gnbList"
:key="key"
:class="{
hidden:
Number(key) < Object.keys(gnbList).length - overflowNam,
}"
>
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
<div class="list-inner">
<div
v-for="(gnbItem, key) in gnbData?.menus"
:key="key"
:class="{
hidden:
Number(key) <
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsArrowDown v-if="gnbItem.children" />
</BlocksHybridLink>
<div v-if="gnbItem.children">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<BlocksHybridLink
:to="child.url_path"
:target="child.link_target"
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
>
<span>{{ gnbItem.menu_name }}</span>
</BlocksHybridLink>
<div v-if="gnbItem.children">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsLinkOut
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</li>
</ul>
<BlocksHybridLink
:to="child.url_path"
:target="child.link_target"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -293,7 +275,9 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
:target="'_self'"
class="nav-1depth text-gradient-pink"
>
<AtomsIconsStarFill />
<span>이벤트</span>
<AtomsIconsStarFill />
</BlocksHybridLink>
</div>
</div>
@@ -304,6 +288,7 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
</AtomsButton>
</div>
<button class="btn-close" @click="handleMenuClose">
<AtomsIconsMenuCloseLine class="mx-auto" />
<span class="sr-only">menu close</span>
</button>
</div>
@@ -317,7 +302,7 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
@apply bg-theme-foreground text-theme-foreground-reversal relative z-50;
}
.game-wrapper {
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[64px] bg-theme-foreground md:h-16 md:pl-0 md:pr-[40px]
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[52px] bg-theme-foreground sm:px-[72px] md:h-16 md:pl-0 md:pr-[40px]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-theme-foreground-reversal-6;
}
.game-wrapper.is-fixed {
@@ -329,10 +314,10 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
.btn-open,
.btn-close {
@apply absolute w-[40px] h-[40px] md:hidden bg-[red];
@apply absolute w-[40px] h-[40px] md:hidden;
}
.btn-open {
@apply top-[4px] left-[12px];
@apply top-[4px] left-[12px] sm:left-[32px];
}
.btn-close {
@apply top-[11px] left-[12px];
@@ -340,9 +325,6 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
.btn-start {
@apply relative mt-2 px-5 md:absolute md:right-0 md:mt-0 md:px-0;
}
.btn-more {
@apply w-[40px] h-[40px] bg-[red];
}
.gnb-game {
@apply absolute top-0 left-0 w-0 md:relative md:w-full md:!h-full;
@@ -355,12 +337,12 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
@apply content-[''] absolute inset-0 w-[100vw] h-full bg-[rgba(0,0,0,0.6)] md:hidden;
}
.gnb-game.is-open .nav-area {
@apply h-full translate-x-0 transition-transform duration-300 md:translate-x-0;
@apply h-full translate-x-0 transition-transform duration-300 md:transform-none;
}
.nav-area {
@apply flex flex-col w-[360px] bg-theme-foreground-10 translate-x-[-100%]
md:inline-flex md:flex-row md:w-auto md:h-full md:pl-[40px] md:items-center md:bg-transparent transform-none;
md:inline-flex md:flex-row md:w-auto md:h-full md:pl-[40px] md:items-center md:bg-transparent md:transform-none;
}
.nav-logo {
@@ -369,7 +351,7 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
.nav-list {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2
md:flex-row md:order-none md:h-full md:my-0 md:mx-10 md:px-0 md:overflow-visible;
md:flex-row md:order-none md:h-full md:my-0 md:ml-10 md:mr-6 md:px-0 md:overflow-visible;
}
.nav-item {
@@ -400,15 +382,15 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
}
.nav-2depth {
@apply block text-[15px] md:hidden md:absolute md:top-[64px] md:left-[-28px] md:min-w-[190px] md:pt-1 md:z-50;
@apply text-[15px] md:hidden md:absolute md:top-[64px] md:left-[-28px] md:pt-1;
}
.nav-2depth ul {
@apply bg-theme-foreground-10 rounded-[20px] md:shadow-lg md:p-3;
@apply bg-theme-foreground-10 rounded-[20px] md:min-w-[190px] md:p-3 md:shadow-lg;
}
.nav-2depth a {
@apply flex items-center gap-1 px-5 py-[9px] rounded-[12px] transition-colors
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10
md:px-4 md:py-[11px];
@apply flex items-center gap-1 py-[9px] px-5 rounded-[12px] transition-colors
md:py-[11px] md:px-4
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
.official {
@@ -416,14 +398,30 @@ onClickOutside(navAreaRef, () => (isMenuOpen.value = false))
}
.more {
@apply relative hidden md:block;
@apply relative hidden ml-[32px] pt-[11px] md:block;
}
.more:hover .more-list {
@apply md:block;
}
.btn-more {
@apply w-[40px] h-[40px] rounded-[12px] bg-theme-foreground-reversal-6 hover:bg-theme-foreground-reversal-10 active:bg-theme-foreground-reversal-4;
}
.more-list {
@apply absolute;
@apply hidden absolute top-[64px] left-[-20px] pt-1;
}
.list-inner {
@apply min-w-[190px] p-3 rounded-[20px] bg-theme-foreground-10 shadow-lg;
}
.more-list a {
@apply flex items-center gap-1 py-[10px] px-4 rounded-[12px] transition-colors
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
.more-list li a {
@apply px-6;
}
.event {
@apply ml-[100px];
@apply relative md:ml-[64px] md:after:content-[''] md:after:absolute md:after:top-[50%] md:after:left-[-32px] md:after:w-[1px] md:after:h-[16px] md:after:bg-theme-foreground-gray-750 md:after:translate-y-[-50%];
}
.is-hidden {

View File

@@ -46,7 +46,7 @@ const currentPosterSrc = computed(() => {
<div class="absolute inset-0 w-full h-full">
<!-- 이미지 타입-->
<div
v-if="resourcesData?.group_type === 'image'"
v-if="isTypeImage(resourcesData?.resource_type)"
class="w-full h-full bg-cover bg-center bg-no-repeat"
:class="getResponsiveClass()"
:style="bgStyles"
@@ -54,7 +54,7 @@ const currentPosterSrc = computed(() => {
<!-- 비디오 타입 -->
<video
v-else-if="resourcesData?.group_type === 'video' && currentVideoSrc"
v-else-if="isTypeVideo(resourcesData?.resource_type) && currentVideoSrc"
class="w-full h-full object-cover"
:poster="currentPosterSrc"
autoplay

View File

@@ -68,7 +68,7 @@ console.log("🚀 11111~ getButtonProps ~ props.resourcesData:", getButtonProps(
>
<AtomsButton
v-for="(button, index) in props.resourcesData"
:key="`${button.group_code}-${index}`"
:key="index"
v-bind="getButtonProps(button)"
v-analytics="useAnalyticsLogDataDirect(getButtonProps(button), props.pageVerTmplSeq)"
class="size-extra-small md:size-medium"

View File

@@ -1,7 +1,10 @@
import * as amplitude from '@amplitude/analytics-browser'
import type { AnalyticsDetailType, AnalyticsLogDataTracking } from '../types/AnalyticsType'
import type {
AnalyticsDetailType,
AnalyticsLogDataTracking,
} from '../types/AnalyticsType'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
import type { IdentityInfo, ActionInfo, MarketingInfo } from '../types/Stove81Plug'
import type { IdentityInfo, ActionInfo, MarketingInfo } from '../types/Stove'
declare const svcLog: any
declare const twq: any
@@ -24,26 +27,31 @@ export const useAnalyticsLogData = (
return ref({} as AnalyticsDetailType)
}
const pageDataTrack = (typeof resourcesData.tracking === 'object' ? resourcesData.tracking : {}) as AnalyticsLogDataTracking
const pageDataTrack = (
typeof resourcesData.tracking === 'object' ? resourcesData.tracking : {}
) as AnalyticsLogDataTracking
console.log('🚀 ~ useAnalyticsLogData ~ pageDataTrack:', pageData)
const logData = ref({
actionType: pageDataTrack?.action_type,
// logSourceType:pageDataTrack.logSourceType,
// viewArea:pageDataTrack.viewArea,
// viewType:pageDataTrack.viewType,
clickArea:pageData.page_name_en,
clickArea: pageData.page_name_en,
clickSarea: pageData.templates[pageVerTmplSeq].page_ver_tmpl_name_en,
clickItem: `${pageData.templates[pageVerTmplSeq].page_ver_tmpl_name}_${pageDataTrack?.click_item}`,
event: pageData.page_name,
eventCategory: `${pageData.page_name}_${pageDataTrack?.click_item}`,
template_code: pageData.templates[pageVerTmplSeq].template_code,
page_ver_tmpl_name: pageData.templates[pageVerTmplSeq].page_ver_tmpl_name,
page_ver_tmpl_name_en: pageData.templates[pageVerTmplSeq].page_ver_tmpl_name_en,
page_ver_tmpl_name_en:
pageData.templates[pageVerTmplSeq].page_ver_tmpl_name_en,
} as unknown as AnalyticsDetailType)
return logData
}
<<<<<<< HEAD
/**
* 페이지 데이터와 템플릿 정보를 기반으로 분석용 로그 데이터를 생성하는 composable (직접 객체 반환)
* @param resourcesData 페이지 리소스 데이터
@@ -82,10 +90,15 @@ export const useAnalyticsLogDataDirect = (
}
=======
>>>>>>> feature/20250910-all
// target에 {XX1, XX2}와 같은 형태가 포함되어 있을 경우 options.clickItem으로부터 값 추출하여 세팅
const findValueFromOption = (target: string, { options = {} }: any) => {
if (target.includes('{') && target.includes('}')) {
const strTargetClickItem = target.substring(target.indexOf('{') + 1, target.indexOf('}'))
const strTargetClickItem = target.substring(
target.indexOf('{') + 1,
target.indexOf('}')
)
const arrTargetClickItem = strTargetClickItem.split(',')
const arrTargetClickItemValue = []
@@ -94,7 +107,10 @@ const findValueFromOption = (target: string, { options = {} }: any) => {
targetClickItem = targetClickItem.trim()
arrTargetClickItemValue.push(options.clickItem[targetClickItem])
}
target = target.replaceAll(`{${strTargetClickItem}}`, arrTargetClickItemValue.join(','))
target = target.replaceAll(
`{${strTargetClickItem}}`,
arrTargetClickItemValue.join(',')
)
}
return target
}
@@ -106,6 +122,10 @@ const findValueFromOption = (target: string, { options = {} }: any) => {
* @param {object} options
*/
const sendGA = (analytics: AnalyticsDetailType, { options = {} }: any) => {
<<<<<<< HEAD
=======
console.log('🚀 ~ 1111 sendGA ~ analytics:', analytics)
>>>>>>> feature/20250910-all
try {
const { gtag } = useGtag()
@@ -119,7 +139,7 @@ const sendGA = (analytics: AnalyticsDetailType, { options = {} }: any) => {
gtag('set', 'cookie_domain', `${window?.location?.hostname || ''}`) // env 값으로 설정 시 쿠키 생성 안 돼서 window.location.hostname으로 설정
gtag('set', 'cookie_expires', '0') // 0으로 설정 시 쿠키가 Session 기반 쿠키로 전환
gtag('event', `${eventName}`, {
event_category: eventLabel
event_category: eventLabel,
})
} catch (e) {
console.error('[Exception] useAnalytics.sendGA: ', e)
@@ -133,8 +153,15 @@ const sendGA = (analytics: AnalyticsDetailType, { options = {} }: any) => {
* @param {string} mcode
* @param {object} options
*/
<<<<<<< HEAD
const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options = {} }: any) => {
console.log("🚀 ~44444 sendSA ~ analytics:", analytics)
=======
const sendSA = (
analytics: AnalyticsDetailType,
{ mcode = '', options = {} }: any
) => {
>>>>>>> feature/20250910-all
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
@@ -152,7 +179,13 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
const viewArea = analytics.viewArea || ''
const viewType = analytics.viewType || ''
const clickArea = analytics.clickArea || ''
<<<<<<< HEAD
const clickSarea = findValueFromOption(analytics.clickSarea || '', { _options })
=======
const clickSarea = findValueFromOption(analytics.clickSarea || '', {
options,
})
>>>>>>> feature/20250910-all
const eventLocale = analytics.eventLocale || ''
const identityInfo: IdentityInfo = {
@@ -162,14 +195,14 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
locale: eventLocale,
lang_cd: eventLocale,
member_no: memberNo,
channeling_cd: 'SO'
channeling_cd: 'SO',
}
const marketingInfo: MarketingInfo = {
marketing_code: mcode || '',
device_type: deviceType,
media_type: '',
media_page: ''
media_page: '',
}
let actionParam = {}
@@ -181,8 +214,13 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
view_info: {
game_no: gameNo,
lang_cd: eventLocale,
<<<<<<< HEAD
..._options?.viewInfo
}
=======
...options?.viewInfo,
},
>>>>>>> feature/20250910-all
}
} else if (actionType === 'click') {
actionParam = {
@@ -192,21 +230,26 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
click_item: analytics.clickItem,
game_no: gameNo,
lang_cd: eventLocale,
<<<<<<< HEAD
..._options?.clickItem
}
=======
...options?.clickItem,
},
>>>>>>> feature/20250910-all
}
}
const actionInfo: ActionInfo = {
action_type: actionType,
action_param: actionParam,
marketing_info: marketingInfo
marketing_info: marketingInfo,
}
const amplitudeActionInfo = {
...actionInfo,
url: `${location?.href || ''}`,
agent: `${navigator?.userAgent || ''}`
agent: `${navigator?.userAgent || ''}`,
}
const amplitudeActionParams: {
@@ -214,7 +257,7 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
event_properties: ActionInfo & { url: string; agent: string }
} = {
event_type: actionType,
event_properties: amplitudeActionInfo
event_properties: amplitudeActionInfo,
}
svcLog.identity(identityInfo)
@@ -232,9 +275,12 @@ const sendSA = (analytics: AnalyticsDetailType, { mcode = '', options: _options
* @param {AnalyticsDetailType} analytics
*/
const sendLog = (locale: string, analytics: AnalyticsDetailType) => {
<<<<<<< HEAD
console.log("🚀 ~33333 sendLog ~ analytics:", analytics)
=======
console.log('🚀 ~ sendLog ~ analytics:', analytics)
>>>>>>> feature/20250910-all
// 언어 코드 대문자 변환
analytics.eventLocale = locale.toUpperCase()
@@ -243,7 +289,7 @@ console.log("🚀 ~33333 sendLog ~ analytics:", analytics)
sendGA(analytics, { options: analytics.options })
// SA 전송 : actionType, logSourceType 유무로 판별
if (
analytics.actionType &&
analytics.actionType &&
analytics.actionType !== ''
// analytics.logSourceType &&
// analytics.logSourceType !== ''
@@ -327,7 +373,7 @@ const sendMarketingScript = ({
gaEventName,
fbEventName,
twEventName,
ttEventName
ttEventName,
}: {
gaEventName?: string
fbEventName?: string

View File

@@ -0,0 +1,191 @@
import { useDebounceFn } from '@vueuse/core'
import { useTokenValidation } from '#layers/composables/useTokenValidation'
import { csrGoStoveLogin } from '#layers/utils/stoveUtil'
export const useCheckGameStart = () => {
// const { tm } = useI18n()
const modalStore = useModalStore()
const runtimeConfig = useRuntimeConfig()
const disabledDoubleClick = ref(false) // 연속 호출 클릭 방지
const isCheckLauncher = ref(false) // 런처 실행 로딩 상태
const isShowDownloadLauncher = ref(false) // 런처 다운로드 표시
const customerService = { title: '확인', link: 'https://www.google.com' } //[TODO]
// 로그인 모달 표시
const showLoginModal = () => {
modalStore.handleOpenAlert({
contentText: '로그인',
confirmButtonText: '스토브 로그인',
className: 'modal-login',
confirmButtonEvent: () => {
modalStore.handleResetAlert()
csrGoStoveLogin()
},
closeButtonEvent: () => {
modalStore.handleResetAlert()
disabledDoubleClick.value = false
},
})
}
// 모든 런처 버튼 비활성화
const setLauncherButtonDisabled = (disabled: boolean) => {
const launcherButton = document.querySelectorAll(
'#btn-launcher'
) as NodeListOf<HTMLButtonElement>
launcherButton.forEach(button => {
button.disabled = disabled
})
}
// 런처 실행 로딩 시작 UI 처리
const startLoadingForLauncher = () => {
if (import.meta.client) {
setLauncherButtonDisabled(true)
isCheckLauncher.value = true
setTimeout(() => {
if (isCheckLauncher.value) {
isShowDownloadLauncher.value = true
}
}, 5000)
}
}
// 런처 실행 로딩 종료 UI 처리
const stopLoadingForLauncher = () => {
if (import.meta.client) {
setLauncherButtonDisabled(false)
isCheckLauncher.value = false
isShowDownloadLauncher.value = false
}
}
// 런처 호출
const runLauncher = async () => {
// 클라이언트에서만 실행
if (!import.meta.client) return
const gameDataStore = useGameDataStore()
const stoveGameId = gameDataStore.gameData?.game_id || ''
const accessTokenSub = useCookie('SUAT')
const nationCookie = useCookie('NNTO').value
const localeCookie = useCookie('LOCALE').value
const isAgent = true
disabledDoubleClick.value = true
window.stoveJsService = window.stoveJsService || {}
// 토큰 유효성 체크
const { validateToken } = useTokenValidation()
const validateTokenResult = await validateToken(accessTokenSub.value || '')
if (validateTokenResult) {
startLoadingForLauncher()
window.stoveJsService.launcher
.run({
gameId: stoveGameId,
nation: nationCookie,
lang: localeCookie,
isSkipMaintenance: isAgent,
})
.then(() => {
// 런처 실행 성공 시 처리
stopLoadingForLauncher()
})
.catch((error: any) => {
// 런처 실행 실패시 처리
if (error.code !== 601) {
stopLoadingForLauncher()
}
errorHandler(error.code)
})
.finally(() => {
disabledDoubleClick.value = false
})
} else {
showLoginModal()
}
}
// PC 클라이언트 설치 전 (에러 처리)
const errorHandler = (errorCode: number) => {
switch (errorCode) {
case 601: // PC 클라이언트 미설치
break
case 40101: // 로그인 정보 확인 중 오류가 발생했습니다. 재로그인 후 다시 이용해 주세요.
modalStore.handleOpenAlert({
contentText:
'로그인 정보 확인 중 오류가 발생했습니다. 재로그인 후 다시 이용해 주세요.',
confirmButtonText: '스토브 로그인',
className: 'modal-login',
confirmButtonEvent: () => {
modalStore.handleResetAlert()
csrGoStoveLogin()
},
})
break
case 40103: // 로그인 정보가 만료되었습니다. 재로그인 후 다시 이용해 주세요.
modalStore.handleOpenAlert({
contentText:
'로그인 정보가 만료되었습니다. 재로그인 후 다시 이용해 주세요.',
confirmButtonText: '스토브 로그인',
className: 'modal-login',
confirmButtonEvent: () => {
modalStore.handleResetAlert()
csrGoStoveLogin()
},
})
break
case 602:
case 504:
case 70051:
case 500000:
case 701:
case 70052:
default:
// 일시적으로 오류가 발생했습니다. 잠시 후 다시 이용해 주세요. 동일한 현상이 계속 발생할 경우 고객센터로 문의해 주세요.
modalStore.handleOpenConfirm({
contentText:
'일시적으로 오류가 발생했습니다. 잠시 후 다시 이용해 주세요. 동일한 현상이 계속 발생할 경우 고객센터로 문의해 주세요.',
confirmButtonText: customerService.title,
cancelButtonText: '취소',
confirmButtonEvent: () => {
modalStore.handleResetConfirm()
window.open(customerService.link, '_blank')
},
})
break
}
}
// 디바운스 설정
const debounceHandler = useDebounceFn(runLauncher, 500)
// 런처 상태 검사
const validateLauncher = () => {
if (!disabledDoubleClick.value) {
debounceHandler()
}
}
// 런처 다운로드 함수
const downloadLauncher = () => {
const stoveClientDownloadUrl = runtimeConfig.public.stoveClientDownloadUrl
location.href = stoveClientDownloadUrl
disabledDoubleClick.value = false
}
return {
disabledDoubleClick, // 연속 클릭 방지
isCheckLauncher, // 런처 실행 로딩 상태
isShowDownloadLauncher, // 런처 다운로드 표시
validateLauncher, // 런처 검사 함수
downloadLauncher, // 런처 실행 함수
stopLoadingForLauncher, // 런처 실행 로딩 종료 함수
}
}

View File

@@ -0,0 +1,30 @@
export function useTokenValidation() {
const runtimeConfig = useRuntimeConfig()
const apiBaseUrl = `${runtimeConfig.public.stoveApiUrl}`
const isTokenValid = ref(false)
const validateToken = async (token: string) => {
try {
const result = await $fetch<{ code: number }>(
`${apiBaseUrl}/auth/v5/user_token/check`,
{
method: 'GET',
headers: {
Authorization: `bearer ${token}`,
'Content-Type': 'application/json;charset=UTF-8',
},
}
)
isTokenValid.value = result.code === 0
return isTokenValid.value
} catch (error) {
isTokenValid.value = false
return false
}
}
return {
isTokenValid,
validateToken,
}
}

View File

@@ -2,19 +2,23 @@ import { defineStore } from 'pinia'
import { useWindowScroll } from '@vueuse/core'
export const useScrollStore = defineStore('scrollStore', () => {
const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' })
const { y: windowY } = useWindowScroll({ behavior: 'smooth' })
const stoveGnbHeight = 48 as number
const scrollXValue = ref('0px')
const isPassedStoveGnb = ref(false)
const scrollGnbPosition = ref(stoveGnbHeight)
const updateScrollValue = () => {
if (stoveGnbHeight <= windowY.value) {
isPassedStoveGnb.value = true
scrollXValue.value = `-${windowX.value}px`
scrollGnbPosition.value = 0
} else {
isPassedStoveGnb.value = false
scrollXValue.value = '0px'
if (windowY.value === 0) {
scrollGnbPosition.value = stoveGnbHeight
} else {
scrollGnbPosition.value = stoveGnbHeight - windowY.value
}
}
}
@@ -28,8 +32,8 @@ export const useScrollStore = defineStore('scrollStore', () => {
return {
stoveGnbHeight,
scrollXValue,
isPassedStoveGnb,
scrollGnbPosition,
updateScrollValue,
controlScrollLock,

10
layers/types/Common.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { HTMLAttributes } from 'vue'
import type { StoveJsService } from '@/layers/types/Stove'
export type ClassType = HTMLAttributes['class']
declare global {
interface Window {
stoveJsService?: StoveJsService
}
}

View File

@@ -1,3 +1,6 @@
/**
* Stove 81 Plug
*/
export interface IdentityInfo {
app_id: string
log_source_type: string
@@ -36,3 +39,10 @@ export interface ActionInfo {
action_param: ActionParam
marketing_info?: MarketingInfo
}
/**
* Stove JS Service
*/
export interface StoveJsService {
launcher?: any
}

View File

@@ -36,12 +36,23 @@ export interface PageDataValue {
lnb_text_color_code_active: string
lnb_text_color_code_deactive: string
lnb_menus: PageDataLnbMenu[]
templates: Record<string, PageDataTemplate>
meta_tag: PageDataMetaTag
templates: Record<string, PageDataTemplate>
}
// ===== 세부 데이터 타입들 =====
// LNB 메뉴 타입
export interface PageDataLnbMenu {
path_code: string
depth: number
sort_order: number
menu_name: string
target_type: number
page_ver_tmpl_name_en: string
tracking: string
}
// 메타 태그 타입
export interface PageDataMetaTag {
x_desc: string
@@ -54,7 +65,14 @@ export interface PageDataMetaTag {
page_title: string
}
// 리소스 그룹 타입
export type PageDataResourceGroupType =
| 'TXT'
| 'BTN'
| 'VID'
| 'IMG_COMM'
| 'IMG_LANG'
| 'IMG_COMM_GLOBAL'
export interface PageDataResourceGroupResPath {
path_mo: string
path_pc?: string
@@ -72,8 +90,7 @@ export interface PageDataResourceGroupBtnInfo {
// 리소스 그룹 타입
export interface PageDataResourceGroup {
group_type?: string
group_code?: string
resource_type?: PageDataResourceGroupType
res_path?: PageDataResourceGroupResPath
btn_info?: PageDataResourceGroupBtnInfo
display?: {
@@ -81,29 +98,27 @@ export interface PageDataResourceGroup {
color_code?: string
color_name?: string
}
tracking: string // JSON 문자열
tracking: string
}
export type PageDataResourceGroups = PageDataResourceGroup[]
// 컴포넌트 타입
export interface PageDataComponent {
groups: PageDataResourceGroup[]
groups: PageDataResourceGroups
}
// 템플릿 컴포넌트 타입 - 두 가지 패턴 지원
export type PageDataTemplateComponent = Record<string, PageDataComponent>
// 그룹 세트 아이템 타입
export type PageDataTemplateComponentSet = PageDataTemplateComponent & {
set_order?: number
}
// 템플릿 컴포넌트 타입 - 두 가지 패턴
export type PageDataTemplateComponents =
| Record<string, PageDataComponent> // 직접 PageDataComponent가 들어있는 패턴
| { group_sets: Record<string, any> } // group_sets 안에 PageDataComponent가 들어있는 패턴
// LNB 메뉴 타입
export interface PageDataLnbMenu {
path_code: string
depth: number
sort_order: number
menu_name: string
target_type: number
page_ver_tmpl_name_en: string
tracking: string // JSON string
}
| PageDataTemplateComponent // 단일 컴포넌트 패턴
| { group_sets: PageDataTemplateComponentSet[] } // 그룹 세트 패턴
// 템플릿 타입
export interface PageDataTemplate {

View File

@@ -55,9 +55,14 @@ const buildRequestOptions = (
url: string,
options: FetchOptions
): FetchRequestOptions => {
const runtimeConfig = useRuntimeConfig()
const callerId = runtimeConfig.public.stoveGameId || ''
const callerDetail = useCookie('sgs_da_uuid').value || ''
let stoveGameId = ''
let callerDetail = ''
if (import.meta.client) {
const gameDataStore = useGameDataStore()
stoveGameId = gameDataStore.gameData?.game_id || ''
callerDetail = useCookie('sgs_da_uuid').value || ''
}
const requestOptions: FetchRequestOptions = {
method,
@@ -75,7 +80,7 @@ const buildRequestOptions = (
if (url.includes('.onstove.com') || url.includes('.gate8.com')) {
requestOptions.headers = {
...requestOptions.headers,
'Caller-Id': callerId as string,
'Caller-Id': stoveGameId as string,
'Caller-Detail': callerDetail as string,
}
}

View File

@@ -6,6 +6,7 @@
import type {
PageDataValue,
PageDataComponent,
PageDataResourceGroupType,
} from '#layers/types/api/pageData'
/**
@@ -19,6 +20,44 @@ export const getLayoutType = (
return pageData?.page_type === 1 ? 'default' : 'promotion'
}
/**
* 이미지 타입인지 확인합니다.
* @param type 리소스 그룹 타입
* @returns 이미지 타입 여부
*/
export const isTypeImage = (type: PageDataResourceGroupType): boolean => {
return (
type === 'IMG_COMM' || type === 'IMG_LANG' || type === 'IMG_COMM_GLOBAL'
)
}
/**
* 비디오 타입인지 확인합니다.
* @param type 리소스 그룹 타입
* @returns 비디오 타입 여부
*/
export const isTypeVideo = (type: PageDataResourceGroupType): boolean => {
return type === 'VID'
}
/**
* 텍스트 타입인지 확인합니다.
* @param type 리소스 그룹 타입
* @returns 텍스트 타입 여부
*/
export const isTypeText = (type: PageDataResourceGroupType): boolean => {
return type === 'TXT'
}
/**
* 버튼 타입인지 확인합니다.
* @param type 리소스 그룹 타입
* @returns 버튼 타입 여부
*/
export const isTypeButton = (type: PageDataResourceGroupType): boolean => {
return type === 'BTN'
}
/**
* 그룹의 첫 번째 데이터를 반환합니다.
* @param source props.components 또는 group 객체

View File

@@ -94,61 +94,6 @@ export const formatTimestamp = (
.replace('ss', seconds)
}
/**
* 타임스탬프를 상대적 시간으로 변환합니다 (예: "3일 전", "2시간 전")
* @param timestamp 타임스탬프 (밀리초 또는 초)
* @param locale 로케일 (기본값: 'ko-KR')
* @returns 상대적 시간 문자열
*/
export const formatRelativeTime = (
timestamp: number | string,
locale: string = 'ko-KR'
): string => {
if (!timestamp) return ''
let ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
if (ts < 10000000000) {
ts = ts * 1000
}
const date = new Date(ts)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return locale === 'ko-KR' ? '방금 전' : 'just now'
}
const diffInMinutes = Math.floor(diffInSeconds / 60)
if (diffInMinutes < 60) {
return locale === 'ko-KR'
? `${diffInMinutes}분 전`
: `${diffInMinutes} minutes ago`
}
const diffInHours = Math.floor(diffInMinutes / 60)
if (diffInHours < 24) {
return locale === 'ko-KR'
? `${diffInHours}시간 전`
: `${diffInHours} hours ago`
}
const diffInDays = Math.floor(diffInHours / 24)
if (diffInDays < 30) {
return locale === 'ko-KR' ? `${diffInDays}일 전` : `${diffInDays} days ago`
}
const diffInMonths = Math.floor(diffInDays / 30)
if (diffInMonths < 12) {
return locale === 'ko-KR'
? `${diffInMonths}개월 전`
: `${diffInMonths} months ago`
}
const diffInYears = Math.floor(diffInMonths / 12)
return locale === 'ko-KR' ? `${diffInYears}년 전` : `${diffInYears} years ago`
}
/**
* 배열 또는 객체를 배열로 변환합니다.
* @param value 변환할 값 (배열, 객체, 또는 undefined/null)

View File

@@ -10,10 +10,11 @@ import { csrFormatJWT } from '#layers/utils/formatUtil'
*/
export const csrGoStoveLogin = () => {
const runtimeConfig = useRuntimeConfig()
const gameDataStore = useGameDataStore()
const loginUrl = runtimeConfig.public.stoveLoginUrl
const stoveGameId = runtimeConfig.public.stoveGameId
const stoveGameNo = runtimeConfig.public.stoveGameNo
const stoveGameId = gameDataStore.gameData?.game_id
const stoveGameNo = gameDataStore.gameData?.game_code
const redirectUrl = encodeURIComponent(location.href)
const url = `${loginUrl}?redirect_url=${redirectUrl}&inflow_path=${stoveGameId}&game_no=${stoveGameNo}`

View File

@@ -3,6 +3,12 @@
* @description 유튜브 관련 유틸리티 함수를 제공합니다.
*/
import { getFirstGroup, isTypeVideo } from '#layers/utils/dataUtil'
import type {
PageDataResourceGroups,
PageDataResourceGroup,
} from '#layers/types/api/pageData'
/**
* 유튜브 URL에서 비디오 ID를 추출합니다.
* @param url - 유튜브 URL (watch, embed, youtu.be 등 다양한 형태 지원)
@@ -95,11 +101,10 @@ export const getYouTubeThumbnailFromUrl = (
* @param source - 미디어 소스 객체
* @returns 미디어 text(src)
*/
export const getMediaText = (source: Record<string, any>): string => {
if (!source) return ''
const resource = source.groups?.[0]
const mediaUrl = resource?.display?.text
return mediaUrl || ''
export const getMediaSrc = (resourceGroup: PageDataResourceGroup): string => {
if (!resourceGroup) return ''
const mediaSrc = resourceGroup?.display?.text
return mediaSrc || ''
}
/**
@@ -109,42 +114,20 @@ export const getMediaText = (source: Record<string, any>): string => {
* @returns 미디어 이미지 소스
*/
export const getMediaImgSrc = (
source: Record<string, any>,
resourceGroups: PageDataResourceGroups,
quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'
): string => {
if (!source) return ''
if (!resourceGroups) return ''
const resource = source.groups?.[0]
const mediaType = resource?.group_type
const mediaUrl = resource?.display?.text
const resourceGroup = getFirstGroup(resourceGroups)
const mediaSrc = getMediaSrc(resourceGroup)
const mediaType = resourceGroup?.resource_type
if (mediaType === 'video' && mediaUrl) {
const videoId = getYouTubeVideoId(mediaUrl)
if (isTypeVideo(mediaType) && mediaSrc) {
const videoId = getYouTubeVideoId(mediaSrc)
const thumbnailUrl = getYouTubeThumbnail(videoId, quality)
return thumbnailUrl
}
return mediaUrl || ''
}
/**
* 미디어 타입을 확인합니다.
* @param source - 미디어 소스 객체
* @returns 미디어 타입 ('video' | 'image' | '')
*/
export const getMediaType = (source: Record<string, any>): string => {
if (!source) return ''
const resource = source.groups?.[0]
const mediaType = resource?.group_type
return mediaType || ''
}
/**
* 비디오 아이템인지 확인합니다.
* @param source - 미디어 소스 객체
* @returns 비디오 여부
*/
export const isVideoItem = (source: Record<string, any>): boolean => {
return getMediaType(source) === 'video'
return mediaSrc || ''
}

View File

@@ -21,7 +21,9 @@ export default {
'theme-foreground-reversal-8': 'var(--foreground-reversal-8)',
'theme-foreground-reversal-10': 'var(--foreground-reversal-10)',
'theme-foreground-reversal-15': 'var(--foreground-reversal-15)',
'theme-foreground-reversal-30': 'var(--foreground-reversal-30)',
'theme-foreground-gray-750': 'var(--foreground-gray-750)',
'theme-foreground-gray-500': 'var(--foreground-gray-500)',
},
},
},