Merge branch 'feature/20250910-all' into feature/20251001-gil
This commit is contained in:
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -11,4 +11,11 @@
|
||||
a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 라이트 테마 색상 */
|
||||
[data-theme='light'] {
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
/* 커스텀 컴포넌트 스타일 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
29
layers/components/atoms/icons/CloseLine.vue
Normal file
29
layers/components/atoms/icons/CloseLine.vue
Normal 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>
|
||||
29
layers/components/atoms/icons/MenuBoldLine.vue
Normal file
29
layers/components/atoms/icons/MenuBoldLine.vue
Normal 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>
|
||||
29
layers/components/atoms/icons/MenuCloseLine.vue
Normal file
29
layers/components/atoms/icons/MenuCloseLine.vue
Normal 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>
|
||||
29
layers/components/atoms/icons/OptionHorizontalFill.vue
Normal file
29
layers/components/atoms/icons/OptionHorizontalFill.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>(), {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
191
layers/composables/useGameStart.ts
Normal file
191
layers/composables/useGameStart.ts
Normal 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, // 런처 실행 로딩 종료 함수
|
||||
}
|
||||
}
|
||||
30
layers/composables/useTokenValidation.ts
Normal file
30
layers/composables/useTokenValidation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
10
layers/types/Common.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 객체
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user