Merge branch 'feature/20250206_all' of https://git.sginfra.net/sgp-web-d/web-template-fe into feature/20250206_all

This commit is contained in:
“hyeonggkim”
2026-01-29 10:51:00 +09:00
68 changed files with 962 additions and 722 deletions

View File

@@ -57,6 +57,7 @@ const createStyleLinks = (faviconJson: GameDataImg, fontPath: string = '') => {
// 메타 태그 생성 헬퍼
const createMetaTags = (metaTag: Partial<GameDataMetaTag> = {}) => {
if (!metaTag) return []
const metaList = [
{ name: 'description', content: metaTag.page_desc },
{ property: 'og:title', content: metaTag.og_title },
@@ -92,11 +93,11 @@ const createStyleCss = (keyColorJson: GameDataKeyColors) => {
// 게임 헤드 설정
const setupGameHead = (data: GameDataValue) => {
try {
const metaTag: Partial<GameDataMetaTag> = data.meta_tag_json ?? {}
const metaTag: Partial<GameDataMetaTag> = data?.meta_tag_json ?? {}
const designTheme = data.design_theme === 1 ? 'light' : 'dark'
const styleLinks = createStyleLinks(
data.favicon_json,
data?.game_font?.font_path
data?.game_font_json?.font_path
)
const styleCss = createStyleCss(data.key_color_json)

View File

@@ -207,7 +207,7 @@ const enabledMarkets = computed(() => {
const logoImgUrl = computed(() => {
const currentLocale = locale.value || 'ko'
const localeData = (webInspectionData.value as any)?.[currentLocale]
return formatPathHost(localeData?.img_json.bi_large)
return formatPathHost(localeData?.img_json?.bi_large)
})
const communityUrl = computed(() => {

View File

@@ -38,8 +38,4 @@
.type-full .splide__arrow--next {
@apply right-10;
}
.splide-arrow svg {
@apply hidden;
}
}

View File

@@ -21,7 +21,6 @@ const componentTag = computed((): string => {
return 'button'
}
})
const componentProps = computed(() => {
switch (props.type) {
case 'link':
@@ -34,7 +33,9 @@ const componentProps = computed(() => {
<template>
<component :is="componentTag" v-bind="componentProps" class="btn-circle">
<slot />
<span class="icon">
<slot />
</span>
<span class="sr-only">{{ props.srOnly }}</span>
</component>
</template>
@@ -51,4 +52,8 @@ const componentProps = computed(() => {
.btn-circle:deep(svg) {
@apply w-[20px] h-[20px] md:w-[24px] md:h-[24px];
}
.icon {
@apply transition-transform duration-300 ease-spring;
}
</style>

View File

@@ -2,6 +2,7 @@
import type { TrackingObject } from '#layers/types/api/common'
interface Props {
bgColor?: string
tracking: TrackingObject
}
@@ -16,15 +17,29 @@ const handlePlayClick = () => {
</script>
<template>
<button class="btn-play" @click="handlePlayClick">
<button
class="btn-play"
:style="{ backgroundColor: props.bgColor }"
@click="handlePlayClick"
>
<span class="icon">
<AtomsIconsArrowRightFill />
</span>
<span class="sr-only">Play</span>
</button>
</template>
<style scoped>
.btn-play {
@apply relative w-[60px] h-[60px] bg-[image:var(--video-play)] bg-cover bg-center bg-no-repeat md:w-[80px] md:h-[80px]
@apply relative flex items-center justify-center rounded-full w-[60px] h-[60px] md:w-[80px] md:h-[80px]
before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full before:border before:border-[rgba(255,255,255,0.5)] before:rounded-full
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-[50%] after:opacity-0 after:transition-opacity after:duration-300 after:ease-in-out
hover:after:opacity-10;
}
.btn-play:hover .icon {
@apply scale-[1.08];
}
.icon {
@apply transition-transform duration-300 ease-spring;
}
</style>

View File

@@ -10,6 +10,8 @@ interface props {
backgroundColor?: string
textColor?: string
disabled?: boolean
gradient?: boolean
useGameFont?: boolean
}
const props = withDefaults(defineProps<props>(), {
@@ -19,8 +21,13 @@ const props = withDefaults(defineProps<props>(), {
target: '_self',
textColor: 'var(--alternative-02)',
disabled: false,
gradient: false,
useGameFont: false,
})
const gameDataStore = useGameDataStore()
const { fontFamily } = storeToRefs(gameDataStore)
const componentTag = computed((): string => {
switch (props.type) {
case 'external':
@@ -29,17 +36,11 @@ const componentTag = computed((): string => {
case 'download':
return props.href ? 'a' : 'button'
case 'internal':
return 'AtomsLocaleLink'
return props.href ? 'AtomsLocaleLink' : 'button'
default:
return 'button'
}
})
const backgroundColor = computed(() => {
if (props.backgroundColor) {
return props.backgroundColor
}
return props.variant === 'filled' ? 'var(--primary)' : 'white'
})
const componentProps = computed(() => {
if (props.type === 'external' || props.type === 'link') {
return {
@@ -49,9 +50,12 @@ const componentProps = computed(() => {
}
if (props.type === 'internal') {
return {
to: props.href,
if (props.href) {
return {
to: props.href,
}
}
return {}
}
if (props.type === 'download') {
@@ -67,6 +71,30 @@ const componentProps = computed(() => {
return {}
})
const textColor = computed(() => {
return props.textColor ? props.textColor : 'var(--text-secondary)'
})
const buttonStyle = computed(() => {
const backgroundColor =
props.variant === 'filled' ? 'var(--primary)' : 'white'
const style: Record<string, string> = {
backgroundColor: props.backgroundColor ?? backgroundColor,
'--disabled-color': textColor.value,
}
return style
})
const textStyle = computed(() => {
const style: Record<string, string> = {
color: textColor.value,
}
if (props.useGameFont && fontFamily.value) {
style.fontFamily = fontFamily.value
}
return style
})
</script>
<template>
@@ -75,29 +103,26 @@ const componentProps = computed(() => {
v-bind="{ ...componentProps }"
:class="['btn-base', props.size, { disabled: props.disabled }]"
:data-variant="props.variant"
:style="{
backgroundColor: backgroundColor,
color: props.textColor ? props.textColor : 'var(--text-secondary)',
'--text-color': props.textColor,
}"
:style="buttonStyle"
:disabled="props.disabled"
>
<span class="btn-content">
<i v-if="props.gradient" class="btn-gradient"></i>
<span class="btn-content" :style="textStyle">
<slot />
<AtomsIconsLongArrowRightLine
v-if="props.type === 'internal'"
:color="props.textColor"
class="icon"
class="icon icon-internal"
/>
<AtomsIconsWebLinkLine
v-if="props.type === 'external'"
:color="props.textColor"
class="icon"
class="icon icon-external"
/>
<AtomsIconsDownloadLine
v-if="props.type === 'download'"
:color="props.textColor"
class="icon"
class="icon icon-download"
/>
</span>
</component>
@@ -108,11 +133,6 @@ const componentProps = computed(() => {
@apply overflow-hidden relative inline-flex items-center justify-center font-medium cursor-pointer
before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full before:border before:border-white/10;
}
.btn-base.disabled {
@apply cursor-default pointer-events-none
after:bg-[var(--text-color)] after:opacity-20 after:z-[2];
}
.btn-base[data-variant='filled'] {
@apply after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:transition-opacity after:duration-300 after:ease-in-out after:opacity-0
hover:after:opacity-20;
@@ -121,6 +141,26 @@ const componentProps = computed(() => {
@apply before:border-[rgba(0,0,0,0.1)]
hover:before:border-[#999];
}
.btn-base.disabled {
@apply cursor-default pointer-events-none
after:opacity-20 after:z-[2];
}
.btn-base.disabled::after {
background-color: var(--disabled-color) !important;
}
.icon {
@apply transition-transform duration-300 ease-spring;
}
.btn-base:hover .icon-internal {
@apply translate-x-[3px];
}
.btn-base:hover .icon-external {
@apply scale-[1.08];
}
.btn-base:hover .icon-download {
@apply translate-y-[3px];
}
.btn-base.disabled .btn-content {
@apply opacity-50;
@@ -131,4 +171,13 @@ const componentProps = computed(() => {
.btn-base.size-extra-small .btn-content {
@apply gap-0.5;
}
.btn-gradient {
@apply absolute top-0 left-0 w-full h-full opacity-[0.7] mix-blend-soft-light;
background: radial-gradient(
68.19% 81.25% at 50.35% 100%,
#fff 20%,
rgba(255, 255, 255, 0) 100%
);
}
</style>

View File

@@ -50,11 +50,9 @@ const onSelectOption = (option: { [key: string | number]: any }): void => {
>
{{ selectedOption }}
</span>
<i
class="inline-flex items-center justify-center w-[14px] h-[14px] shrink-0"
>
<AtomsIconsSelectArrowDownFill
:size="12"
<i class="inline-flex items-center justify-center shrink-0">
<AtomsIconsArrowDownFill
:size="14"
color="#333333"
:class="isActive ? 'rotate-180' : ''"
/>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
fillOpacity?: number
}
withDefaults(defineProps<Props>(), {
size: 24,
color: '#ffffff',
fillOpacity: 0.5,
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
:fill="color"
:fill-opacity="fillOpacity"
>
<path
d="M13 9.41422L17.2929 13.7071C17.6834 14.0976 18.3166 14.0976 18.7071 13.7071C19.0976 13.3166 19.0976 12.6834 18.7071 12.2929L12.7078 6.29361C12.5289 6.1143 12.2822 6.00257 12.0094 6.00005C12.0063 6.00002 12.0032 6.00001 12 6.00001C11.9968 6.00001 11.9937 6.00002 11.9906 6.00005C11.7269 6.00249 11.4877 6.10694 11.3104 6.27585C11.3045 6.28145 11.2987 6.28714 11.2929 6.2929L5.29289 12.2929C4.90237 12.6834 4.90237 13.3166 5.29289 13.7071C5.68341 14.0976 6.31658 14.0976 6.7071 13.7071L11 9.41422L11 20C11 20.5523 11.4477 21 12 21C12.5523 21 13 20.5523 13 20L13 9.41422Z"
/>
<path
d="M19.5 4.00001C19.5 4.55229 19.0523 5 18.5 5L5.5 5C4.94771 5 4.5 4.55228 4.5 4C4.5 3.44772 4.94771 3 5.5 3L18.5 3C19.0523 3 19.5 3.44772 19.5 4.00001Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
fillOpacity?: number
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#ffffff',
fillOpacity: 0.5,
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 32 32"
:fill="color"
:fill-opacity="fillOpacity"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.4839 25.0812C12.7639 26.0629 11.079 26.9852 9.42912 27.8405C8.49538 28.3239 7.40719 27.6545 7.33699 26.5612C7.21062 24.62 7.11935 22.6119 7.06318 20.5443C7.02106 19.0568 7 17.5396 7 16C7 14.4604 7.02106 12.9506 7.06318 11.4557C7.11935 9.38809 7.21062 7.37997 7.33699 5.43879C7.40719 4.34548 8.49538 3.6761 9.42912 4.15954C11.079 5.01485 12.7709 5.9371 14.4839 6.91884C15.7196 7.6254 16.9692 8.36171 18.2259 9.13521C19.4826 9.90871 20.7112 10.6822 21.9047 11.4631C23.5686 12.549 25.1622 13.6349 26.6857 14.7208C27.5492 15.3306 27.5492 16.6694 26.6857 17.2793C25.1622 18.3651 23.5615 19.4584 21.9047 20.5369C20.7112 21.3178 19.4826 22.0987 18.2259 22.8648C16.9692 23.6383 15.7196 24.3672 14.4839 25.0812Z"
/>
</svg>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.06454 4.95333L0.204544 1.16667C-0.228789 0.74 0.0712106 0 0.684544 0L9.31787 0C9.9312 0 10.2312 0.74 9.79787 1.16667L5.93787 4.95333C5.41787 5.46 4.59121 5.46 4.07121 4.95333H4.06454Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -31,4 +31,7 @@ const analytics = {
@apply fixed top-3 right-3 mt-[calc(var(--scroll-position,48px)+48px)] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] z-[100]
sm:top-5 md:top-6 md:right-8 md:mt-[calc(var(--scroll-position,64px)+64px)];
}
.btn-home:hover :deep(.icon) {
@apply scale-[1.08];
}
</style>

View File

@@ -16,12 +16,14 @@ interface Props {
iconComponent?: Component
iconProps?: Record<string, any>
disabled?: boolean
useGameFont?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
variant: 'filled',
disabled: false,
useGameFont: false,
})
const runtimeConfig = useRuntimeConfig()
@@ -31,7 +33,7 @@ const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const { isProcessing, validateLauncher } = useCheckGameStart()
const { gameName, platformType, osType, marketJson } =
const { gameName, platformType, osType, marketJson, fontFamily } =
storeToRefs(gameDataStore)
const PLATFORM_ICON_MAP: Record<Platform, string> = {
@@ -40,12 +42,11 @@ const PLATFORM_ICON_MAP: Record<Platform, string> = {
pc: 'AtomsIconsLogoWindow',
stove: 'AtomsIconsLogoStove',
} as const
const DUP_IMAGE_MAP: Record<Platform, string> = {
google_play: '/images/common/btn_logo-google.svg',
app_store: '/images/common/btn_logo-app.svg',
pc: '/images/common/btn_logo-pc.svg',
stove: '/images/common/btn_logo-stove.svg',
google_play: '/images/common/btn_launcher/btn_logo-google.svg',
app_store: '/images/common/btn_launcher/btn_logo-app.svg',
pc: '/images/common/btn_launcher/btn_logo-pc.svg',
stove: '/images/common/btn_launcher/btn_logo-stove.svg',
} as const
const componentTag = computed(() => {
@@ -54,7 +55,17 @@ const componentTag = computed(() => {
}
return 'button'
})
const isSingle = computed(() => props.type === 'single')
const shouldShowPlatformIcon = computed(
() =>
(props.type === 'default' && props.variant !== 'custom') ||
props.type === 'single'
)
const shouldShowDownloadIcon = computed(
() =>
props.platform === 'pc' &&
props.type === 'default' &&
props.variant !== 'custom'
)
const supportedPlatforms = computed(
() =>
getSupportedPlatforms(
@@ -63,18 +74,27 @@ const supportedPlatforms = computed(
) as PlatformTransformType[]
)
const platformIcon = computed(() => PLATFORM_ICON_MAP[props.platform])
const inlineStyle = computed<CSSProperties>(() => {
const buttonStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.backgroundColor) {
style.backgroundColor = props.backgroundColor
}
if (props.textColor) {
style.color = props.textColor
}
if (props.type === 'duplication') {
style.backgroundImage = `url(${formatPathHost(DUP_IMAGE_MAP[props.platform], { imageType: 'common' })})`
}
return style
})
const textStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.textColor) {
style.color = props.textColor
}
if (props.useGameFont && fontFamily.value) {
style.fontFamily = fontFamily.value
}
return style
})
@@ -140,21 +160,19 @@ const handleClick = () => {
:class="[
'btn-base',
props.type,
{ 'no-text': isSingle && !$slots.default },
{ 'no-text': props.type === 'single' && !$slots.default },
]"
:data-variant="props.variant"
:data-platform="props.platform"
:style="inlineStyle"
:style="buttonStyle"
:disabled="disabled || isProcessing"
@click="handleClick"
>
<span class="btn-content">
<component
:is="platformIcon"
v-if="props.type !== 'duplication'"
class="icon-platform"
/>
<span class="text">
<span v-if="shouldShowPlatformIcon" class="icon-platform">
<component :is="platformIcon" />
</span>
<span class="text" :style="textStyle">
<slot />
</span>
<component
@@ -162,10 +180,7 @@ const handleClick = () => {
v-if="props.iconComponent"
v-bind="props.iconProps"
/>
<span
v-if="props.platform === 'pc' && props.type === 'default'"
class="icon-download"
>
<span v-if="shouldShowDownloadIcon" class="icon-download">
<AtomsIconsDownloadLine />
</span>
</span>
@@ -180,7 +195,10 @@ const handleClick = () => {
@apply relative flex items-center w-full z-[1];
}
.icon-platform {
@apply w-5 h-5 flex-shrink-0;
@apply w-5 h-5 mr-2 flex-shrink-0;
}
.icon-download {
@apply ml-auto pl-4;
}
.btn-base[data-variant='filled'] {
@@ -212,12 +230,6 @@ const handleClick = () => {
@apply line-clamp-2 text-[14px]
md:text-[16px];
}
.btn-base.default .icon-platform + .text {
@apply pl-2;
}
.btn-base.default .icon-download {
@apply ml-auto pl-4;
}
.btn-base.default[data-variant='outlined'] .icon-download {
@apply border-black/10;

View File

@@ -26,7 +26,9 @@ const handleScrollToTop = () => {
class="btn-top"
sr-only="top"
@click="handleScrollToTop"
/>
>
<AtomsIconsArrowControlTopLine />
</AtomsButtonCircle>
</Transition>
</template>
@@ -34,4 +36,7 @@ const handleScrollToTop = () => {
.btn-top {
@apply bg-[image:var(--button-top)] bg-center bg-cover bg-no-repeat;
}
.btn-top:hover :deep(.icon) {
@apply -translate-y-[3px];
}
</style>

View File

@@ -10,6 +10,13 @@ const props = defineProps<Props>()
const { locale } = useI18n()
const { sendLog } = useAnalytics()
const getArrowBgColor = (direction: 'prev' | 'next') => {
return getColorCodeFromData(
props.arrowsData?.[direction === 'prev' ? 0 : 1]?.display,
'none'
)
}
const handleArrowClick = (direction: 'prev' | 'next') => {
if (props.arrowsData) {
const arrowIndex = direction === 'prev' ? 0 : 1
@@ -23,12 +30,28 @@ const handleArrowClick = (direction: 'prev' | 'next') => {
<AtomsButtonCircle
sr-only="Previous"
class="splide-arrow splide__arrow--prev"
:style="{ backgroundColor: getArrowBgColor('prev') }"
@click="handleArrowClick('prev')"
/>
>
<AtomsIconsArrowRightLine color="#ffffff" />
</AtomsButtonCircle>
<AtomsButtonCircle
sr-only="Next"
class="splide-arrow splide__arrow--next"
:style="{ backgroundColor: getArrowBgColor('next') }"
@click="handleArrowClick('next')"
/>
>
<AtomsIconsArrowRightLine color="#ffffff" />
</AtomsButtonCircle>
</div>
</template>
<style scoped>
.splide__arrow--prev:hover :deep(.icon) {
@apply -translate-x-[3px];
}
.splide__arrow--next:hover :deep(.icon) {
@apply translate-x-[3px];
}
</style>

View File

@@ -436,7 +436,7 @@ onMounted(() => {
>
{{ t('Text_MonthYear', { month: month + 1, year: year }) }}
</span>
<AtomsIconsSelectArrowDownFill :size="10" color="#333333" />
<AtomsIconsArrowDownFill :size="16" color="#333333" />
</button>
<button
type="button"

View File

@@ -70,7 +70,7 @@
<svg
v-else
class="w-3 h-3 text-gray-300 transition-transform duration-200"
:class="{ 'rotate-180': isDropdownOpen }"
:class="{ 'rotate-180': !isDropdownOpen }"
viewBox="0 0 12 12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
@@ -125,7 +125,6 @@
</template>
<script setup lang="ts">
interface LanguageOrder {
languageOrder?: any[]
}
@@ -154,29 +153,29 @@ const availableLanguages = computed(() => {
if (filteredLanguages.length === 0) {
return [{ code: 'ko', name: '한국어' }]
}
//languageOrder 값이 있는 경우 정렬, 없는 경우 기본 순서 유지
const defaultLanguageOrder = [ 'ko', 'en', 'ja', 'zh-cn', 'zh-tw', 'th' ]
//languageOrder 값이 있는 경우 정렬, 없는 경우 기본 순서 유지
const defaultLanguageOrder = ['ko', 'en', 'ja', 'zh-cn', 'zh-tw', 'th']
const languageOrder = props.languageOrder || defaultLanguageOrder
// 정렬: 우선순위 언어 먼저, 그 다음 나머지
const sortedLanguages = filteredLanguages.sort((a, b) => {
const indexA = languageOrder.indexOf(a.code)
const indexB = languageOrder.indexOf(b.code)
// 둘 다 우선순위 목록에 있는 경우
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB
}
// a만 우선순위 목록에 있는 경우
if (indexA !== -1) {
return -1
}
// b만 우선순위 목록에 있는 경우
if (indexB !== -1) {
return 1
}
// 둘 다 우선순위 목록에 없는 경우 원래 순서 유지
return 0
})
@@ -230,7 +229,6 @@ const switchLanguage = async () => {
const localeCookie = useCookie('LOCALE', {
domain: baseDomain,
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위)
sameSite: 'lax',
})
localeCookie.value = selectedLocale.value.toLowerCase()

View File

@@ -17,8 +17,10 @@ const OBSERVER_OPTIONS = {
rootMargin: '-20% 0px -60% 0px', // 상단 20%, 하단 60% 마진
threshold: 0,
} as const
const AUTO_HIDE_MS = 5000
const isShowLnbWithScroll = ref(false)
let autoHideTimer: ReturnType<typeof setTimeout> | null = null
const isShowLnbWithScroll = ref(true)
const activeSection = ref<string>('')
const lnbList = computed<Record<string, PageDataLnbMenu>>(
@@ -72,6 +74,20 @@ const handleIntersection = (entries: IntersectionObserverEntry[]) => {
const observer = new IntersectionObserver(handleIntersection, OBSERVER_OPTIONS)
const clearAutoHide = () => {
if (autoHideTimer) {
clearTimeout(autoHideTimer)
autoHideTimer = null
}
}
const scheduleAutoHide = () => {
clearAutoHide()
autoHideTimer = setTimeout(() => {
isShowLnbWithScroll.value = false
}, AUTO_HIDE_MS)
}
// 요소 관찰 헬퍼 함수
const observeElement = (elementId: string) => {
const element = document.getElementById(elementId)
@@ -112,15 +128,28 @@ const handleLnbClick = (lnbItem: PageDataLnbMenu) => {
}
watch(directions, newVal => {
// 스크롤 업일 때만 표시, 다운이거나 멈춘 상태에서는 숨김
isShowLnbWithScroll.value = newVal.top === true
// 스크롤 위로: 즉시 노출 + 5초 후 자동 숨김
if (newVal.top === true) {
isShowLnbWithScroll.value = true
scheduleAutoHide()
return
}
// 스크롤 아래로: 즉시 숨김 (딜레이 없음)
if (newVal.bottom === true) {
clearAutoHide()
isShowLnbWithScroll.value = false
}
})
onMounted(() => {
observeSections()
isShowLnbWithScroll.value = true
scheduleAutoHide()
})
onUnmounted(() => {
clearAutoHide()
observer.disconnect()
})
</script>
@@ -193,7 +222,7 @@ onUnmounted(() => {
z-index: -1;
}
.lnb-wrap.is-hidden {
@apply translate-x-[110%] delay-[5s];
@apply translate-x-[110%];
}
.lnb-main {
@apply flex flex-col gap-4 items-end;

View File

@@ -105,7 +105,7 @@ const handlePagination = (page: number) => {
:class="[
'!w-full !h-full p-0 rounded-full text-center text-[14px] font-[500] leading-[24px] tracking-[-0.42px]',
page === currentPage
? '!bg-[#C7AE8B] !text-white cursor-default'
? '!bg-[var(--primary)] !text-white cursor-default'
: '',
]"
@click="handlePagination(page)"

View File

@@ -42,11 +42,11 @@ const handleCopy = async () => {
</script>
<template>
<div v-if="Object.keys(snsJson).length > 0" class="sns-container">
<div v-if="Object.keys(snsJson).length > 0" class="sns-wrap">
<transition name="fade">
<AtomsButtonCircle
v-show="!showSnsList"
class="btn-sns"
class="btn-more"
sr-only="sns"
:style="{ backgroundColor: snsBackgroundColor }"
@click="handleControlForce(true)"
@@ -55,36 +55,45 @@ const handleCopy = async () => {
</AtomsButtonCircle>
</transition>
<transition name="fade">
<div
v-show="showSnsList"
class="sns-list"
:style="{ backgroundColor: snsBackgroundColor }"
>
<div v-show="showSnsList" class="sns-list">
<template v-for="(item, key) in snsJson" :key="key">
<a
<AtomsButtonCircle
v-if="item.use_yn === 1 && item.url"
:href="item.url"
type="link"
:to="item.url"
target="_blank"
class="sns-item"
rel="noopener noreferrer"
:style="{
backgroundImage: `url(${formatPathHost(`/images/common/ic-v2-logo-${key}-fill.png`, { imageType: 'common' })})`,
}"
:class="['btn-sns', key]"
:sr-only="key"
@click="sendLog(locale, { ...analytics, click_item: key })"
>
<span class="sr-only">{{ key }}</span>
</a>
<img
width="100%"
height="100%"
:src="
formatPathHost(`/images/common/ic-v2-logo-${key}-fill.png`, {
imageType: 'common',
})
"
:alt="key"
/>
</AtomsButtonCircle>
</template>
<button
type="button"
class="sns-item"
:style="{
backgroundImage: `url(${formatPathHost('/images/common/ic-v2-community-link-line.png', { imageType: 'common' })})`,
}"
<AtomsButtonCircle
class="btn-sns link"
sr-only="copy"
@click="handleCopy"
>
<span class="sr-only">copy</span>
</button>
<img
width="100%"
height="100%"
:src="
formatPathHost('/images/common/ic-v2-community-link-line.png', {
imageType: 'common',
})
"
alt="copy"
/>
</AtomsButtonCircle>
<div class="close-container">
<button
type="button"
@@ -101,17 +110,47 @@ const handleCopy = async () => {
</template>
<style scoped>
.sns-container {
@apply relative h-[40px] md:h-[48px];
.sns-wrap {
@apply fixed bottom-3 left-3 h-[40px] md:bottom-8 md:left-8 md:h-[48px] z-[100];
}
.btn-sns:hover .icon-share {
.btn-more:hover .icon-share {
@apply fill-white;
}
.btn-sns {
@apply bg-center bg-no-repeat bg-[rgba(255,255,255,0.6)] after:hidden;
}
.btn-sns.kakao:hover {
@apply bg-[#FAE100];
}
.btn-sns.kakao:hover img {
filter: brightness(0) saturate(100%) invert(13%) sepia(2%) saturate(0%)
hue-rotate(0deg) brightness(100%) contrast(100%);
}
.btn-sns.tiktok:hover {
@apply bg-[#000];
}
.btn-sns.discord:hover {
@apply bg-[#000];
}
.btn-sns.twitter:hover {
@apply bg-[#000];
}
.btn-sns.youtube:hover {
@apply bg-[#FF0000];
}
.btn-sns.facebook:hover {
@apply bg-[#1977F2];
}
.btn-sns.instagram:hover {
@apply bg-[#000];
}
.btn-sns.link:hover {
@apply bg-[#000];
}
.sns-list {
@apply absolute bottom-0 right-0 flex items-center justify-center gap-4 rounded-full h-full pl-4 pr-3
before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full before:border before:border-[rgba(255,255,255,0.06)] before:rounded-full;
@apply absolute bottom-0 left-0 flex items-center justify-center gap-4 rounded-full h-full pl-4 pr-3;
}
.sns-item {
@apply w-[24px] h-[24px] bg-center bg-cover bg-no-repeat opacity-50 z-[1]

View File

@@ -53,6 +53,7 @@ watch(
:class="[
'modal-wrap',
{ 'is-open': content.storeIsOpen },
{ dimmed: content.storeIsShowDimmed },
content.storeModalName,
]"
@click="handleOutsideClick"

View File

@@ -7,8 +7,9 @@ import type {
const { locale } = useI18n()
const { sendLog } = useAnalytics()
const gameDomain = getGameDomain()
const breakpoints = useResponsiveBreakpoints()
const analytics = {
action_type: 'click',
click_sarea: 'EventNavigation',
@@ -53,18 +54,21 @@ const toggleEventNavigation = () => {
onMounted(async () => {
eventNavigationList.value = await getEventNavigation()
if (breakpoints.value.isMobile) {
isEventNavigationOpen.value = false
}
})
</script>
<template>
<div
v-if="Object.keys(eventNavigationList).length > 1"
class="event-navigation"
class="navigation-wrap"
:class="{
'is-closed': !isEventNavigationOpen,
}"
>
<div class="navigation-wrapper">
<div class="navigation-container">
<AtomsButtonCircle
sr-only="event navigation control"
class="btn-control"
@@ -105,13 +109,17 @@ onMounted(async () => {
</template>
<style scoped>
.empty-game + main .event-navigation {
.empty-game + main .navigation-wrap {
@apply mt-[var(--scroll-position,48px)];
}
.event-navigation {
@apply fixed top-0 left-0 bottom-0 mt-[calc(var(--scroll-position,48px)+48px)] md:mt-[calc(var(--scroll-position,64px)+64px)] z-[100] transition-transform duration-300 ease-in-out;
.sns-wrap ~ .navigation-wrap {
@apply pb-[78px];
}
.navigation-wrapper {
.navigation-wrap {
@apply fixed top-0 left-0 bottom-0 mt-[calc(var(--scroll-position,48px)+48px)] md:mt-[calc(var(--scroll-position,64px)+64px)] z-[90] transition-transform duration-300 ease-in-out;
}
.navigation-container {
@apply relative h-full p-3 sm:p-5 sm:pr-3
md:p-8 md:pt-6 md:pr-4;
}
@@ -132,14 +140,17 @@ onMounted(async () => {
@apply absolute top-3 right-[-40px] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] rotate-180
sm:top-5 md:top-6 md:right-[-48px];
}
.btn-control:hover :deep(.icon) {
@apply translate-x-[3px];
}
.event-navigation.is-closed {
.navigation-wrap.is-closed {
@apply translate-x-[calc(-100%+20px)] sm:translate-x-[calc(-100%+40px)];
}
.event-navigation.is-closed .btn-control {
.navigation-wrap.is-closed .btn-control {
@apply rotate-0;
}
.event-navigation.is-closed .navigation-list {
.navigation-wrap.is-closed .navigation-list {
@apply pointer-events-none opacity-0;
}

View File

@@ -20,33 +20,6 @@ const { tm, locale }: any = useI18n({
messages: Object(resultGetMultilingual?.value?.multilingual),
})
// Footer_caution 값이 있고 빈 객체가 아닌지 체크
const hasCautionText = computed(() => {
const value = tm('Footer_caution')
// null, undefined 체크
if (value === null || value === undefined) {
return false
}
// 빈 객체 체크
if (
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
return false
}
// 문자열로 변환하여 빈 문자열 또는 '{}' 문자열 체크
const stringValue = String(value).trim()
if (stringValue === '' || stringValue === '{}') {
return false
}
return true
})
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
// const path = ref<string>(`${staticUrl}/local/template/${gameData.value.s3_folder_name}`)
@@ -180,35 +153,29 @@ const footerAgeRatingInfo = computed((): string[] => {
</script>
<template>
<footer id="footer" ref="footerRef" class="relative bg-blac">
<footer id="footer" class="relative px-5 sm:px-10">
<div
class="inner relative max-w-7xl mx-auto px-5 py-4 text-[12px] text-gray-400 md:px-4 md:py-9 md:text-[12px]"
class="relative max-w-[1300px] mx-auto pt-4 pb-10 text-[13px] text-white/50"
>
<ClientOnly>
<div class="menu-area py-4 pb-4">
<ul class="flex items-center flex-wrap gap-x-6 gap-y-2">
<li
v-for="(footerMenuItem, index) in footerLinks"
:key="index"
class="text-[15px] text-white/50 md:tracking-[-0.5px] relative flex items-center"
>
<div class="py-4 border-b border-white/10">
<ul
class="flex items-center flex-wrap gap-x-6 gap-y-2 text-[15px] tracking-[-0.5px]"
>
<li v-for="(footerMenuItem, index) in footerLinks" :key="index">
<NuxtLink
:to="footerMenuItem.url"
:target="footerMenuItem.target"
:class="[
footerMenuItem.active === 'y' && 'text-white/50',
index === 2 && 'text-[#fff]',
'hover:text-gray-600 transition-colors',
index === 2 && 'text-white',
]"
>
{{ footerMenuItem.title }}
</NuxtLink>
</li>
<li v-if="useGameRating" class="relative">
<button
class="text-[15px] text-white/50 hover:text-gray-600 transition-colors"
@click="toggleAgeRating"
>
<button @click="toggleAgeRating">
<em v-dompurify-html="tm('Footer_AgeRating')"></em>
</button>
<div
@@ -335,37 +302,26 @@ const footerAgeRatingInfo = computed((): string[] => {
</li>
</ul>
</div>
<div class="address-area mt-4 hidden sm:block">
<address class="not-italic text-white/50">
<div class="row my-1.5 leading-5">
<span
v-dompurify-html="tm('Footer_Address')"
class="text-[13px] [&_a]:cursor-pointer [&_a]:text-white/50 [&_a]:underline"
></span>
</div>
</address>
</div>
<div
class="language-area static md:absolute bottom-7 right-10 text-white mt-5 md:mt-0 md:bottom-5.5 md:right-4"
class="flex flex-col items-start mt-4 gap-4 sm:gap-5 md:gap-6 md:flex-row md:justify-between"
>
<address class="hidden not-italic leading-5 sm:block">
<div
v-dompurify-html="tm('Footer_Address')"
class="text-[13px] [&_a]:cursor-pointer [&_a]:text-white/50 [&_a]:underline"
/>
</address>
<BlocksLanguageSwitcher
v-if="gameData?.lang_codes?.length > 1"
:language-order="tm('Footer_Language_Order')"
/>
</div>
<div v-if="hasCautionText" class="mt-6 md:mt-6 hidden sm:block">
<div
v-dompurify-html="tm('Footer_caution')"
class="text-xs text-white/30"
></div>
</div>
<div class="copyright-area mt-6 text-[13px] text-white/50 md:mt-4">
<div class="mt-5">
<span v-dompurify-html="tm('Footer_Copyright')"></span>
</div>
<div class="logo-area flex items-center gap-7 mt-6 md:mt-6">
<div class="flex items-center gap-7 mt-6 md:mt-6">
<span>
<a
:href="tm('Footer_Smilegate_Link')"
@@ -378,8 +334,8 @@ const footerAgeRatingInfo = computed((): string[] => {
imageType: 'common',
})
"
width="114px"
alt="스마일게이트 로고"
class="w-auto h-auto"
/>
</a>
</span>

View File

@@ -174,6 +174,9 @@ onMounted(() => {
// 화면 크기 변경 시 오버플로우 재계산
watch(width, () => {
throttledCalculateOverflow()
if (isMenuOpen.value && breakpoints.value.isDesktop) {
handleMenuClose()
}
})
})
</script>
@@ -375,12 +378,16 @@ onMounted(() => {
<template v-if="start1depthData">
<BlocksButtonLauncher
platform="pc"
variant="custom"
:background-color="
getColorCodeFromData(start1depthData?.btn_info, 'btn')
"
:text-color="
getColorCodeFromData(start1depthData?.btn_info, 'txt')
"
:use-game-font="
start1depthData?.btn_info?.use_game_font === 1
"
@click="sendLog(locale, start1depthData.tracking)"
>
{{ start1depthData?.btn_info?.txt_btn_name }}
@@ -573,12 +580,9 @@ onMounted(() => {
.btn-start:hover .nav-2depth {
@apply md:block;
}
.btn-start:deep(.btn-base[data-variant='filled']) {
.btn-start:deep(.btn-base.default[data-variant='custom']) {
@apply w-full h-[48px] px-10 font-[700] text-[16px];
}
.btn-start:deep(.btn-base[data-variant='filled']) svg {
@apply hidden;
}
.btn-start .nav-2depth {
@apply left-[unset] right-[-40px];
@@ -593,9 +597,6 @@ onMounted(() => {
.btn-start .nav-2depth:deep(.btn-base) .text {
@apply pl-1.5 text-[15px] text-theme-foreground-reversal;
}
.btn-start:deep(.nav-2depth .icon-download) {
@apply hidden;
}
[data-theme='light'] {
.btn-start .nav-2depth:deep(.btn-base) .icon-platform {

View File

@@ -12,11 +12,7 @@ interface Props {
const props = defineProps<Props>()
const mainContentRef = ref<HTMLElement>()
const { tm, locale } = useI18n()
const { height: viewportH } = useWindowSize()
const { bottom: mainBottom } = useElementBounding(mainContentRef)
const { getTemplateComponent } = useTemplateRegistry()
const loadingStore = useLoadingStore()
const modalStore = useModalStore()
@@ -26,11 +22,6 @@ const { isPAssApiLoading, hasApiCallStarted } = storeToRefs(loadingStore)
// 개별 메타 태그 표시 여부 확인
const shouldShowMetaTag = computed(() => props.pageData?.meta_tag_type === 2)
const pinToMain = computed(() => {
if (!mainBottom.value) return false
return mainBottom.value <= viewportH.value
})
// 템플릿 표시 여부 확인
const isTemplateVisible = (template: PageDataTemplate): boolean => {
return Boolean(
@@ -96,7 +87,7 @@ onMounted(() => {
</script>
<template>
<div ref="mainContentRef" class="main-content">
<div class="content-wrap">
<template
v-for="(template, index) in visibleTemplates"
:key="template.template_code ?? index"
@@ -112,33 +103,27 @@ onMounted(() => {
</div>
<ClientOnly>
<BlocksLnb v-if="isShowLnb" />
<div
v-if="isShowTopBtn || isShowSnsBtn"
:class="['utile-wrap', { 'is-stop': pinToMain }]"
>
<div v-if="isShowTopBtn" class="utile-wrap">
<BlocksButtonScrollTop v-if="isShowTopBtn" />
<BlocksSns v-if="isShowSnsBtn" />
</div>
</ClientOnly>
<BlocksSns v-if="isShowSnsBtn" />
</template>
<style scoped>
.empty-game + main .main-content {
.empty-game + main .content-wrap {
@apply pt-0;
}
.main-content {
.content-wrap {
@apply relative pt-[48px] md:pt-[64px];
}
.utile-wrap {
@apply fixed flex flex-col items-end z-[100]
bottom-[12px] right-[12px] gap-2 md:bottom-[40px] md:right-[40px] md:gap-3;
}
.utile-wrap.is-stop {
@apply absolute;
}
[data-theme='light'] {
.main-content {
.content-wrap {
@apply bg-theme-foreground;
}
}

View File

@@ -122,7 +122,8 @@ const handleButtonClick = (button: PageDataResourceGroup) => {
<template>
<div
v-if="buttonList.length"
class="flex flex-wrap justify-center items-center gap-3 md:gap-4"
v-motion-stagger
class="flex flex-wrap justify-center gap-3 md:gap-4"
>
<template v-for="(button, index) in buttonList" :key="index">
<template v-if="button.btn_info?.detail?.btn_type === 'RUN'">
@@ -132,6 +133,7 @@ const handleButtonClick = (button: PageDataResourceGroup) => {
:platform="button.btn_info?.detail?.market_type"
:background-color="getColorCodeFromData(button.btn_info, 'btn')"
:text-color="getColorCodeFromData(button.btn_info, 'txt')"
:use-game-font="button.btn_info?.use_game_font === 1"
@click="handleButtonClick(button)"
>
{{ button.btn_info?.txt_btn_name }}
@@ -147,6 +149,8 @@ const handleButtonClick = (button: PageDataResourceGroup) => {
:background-color="getColorCodeFromData(button.btn_info, 'btn')"
:text-color="getColorCodeFromData(button.btn_info, 'txt')"
:disabled="button.btn_info?.detail?.btn_type === 'DEACTIVE'"
:gradient="true"
:use-game-font="button.btn_info?.use_game_font === 1"
@click="handleButtonClick(button)"
>
{{ button.btn_info?.txt_btn_name }}

View File

@@ -7,7 +7,7 @@ const props = defineProps<{
</script>
<template>
<p class="description">
<p v-motion-stagger class="description">
<BlocksVisualContent :resources-data="props.resourcesData" />
</p>
</template>

View File

@@ -7,7 +7,7 @@ const props = defineProps<{
</script>
<template>
<h2>
<h2 v-motion-stagger>
<BlocksVisualContent :resources-data="props.resourcesData" />
</h2>
</template>

View File

@@ -12,7 +12,7 @@ const props = withDefaults(defineProps<Props>(), {
</script>
<template>
<component :is="props.tag">
<component :is="props.tag" v-motion-stagger>
<BlocksVisualContent :resources-data="props.resourcesData" />
</component>
</template>

View File

@@ -7,6 +7,10 @@ const props = defineProps<{
const modalStore = useModalStore()
const bgColor = computed(() => {
return getColorCodeFromData(props.resourcesData.display, 'none')
})
// 비디오 플레이 버튼 클릭 핸들러
const handleVideoPlayClick = () => {
const youtubeUrl = props.resourcesData?.display?.text ?? ''
@@ -18,6 +22,8 @@ const handleVideoPlayClick = () => {
<template>
<AtomsButtonPlay
v-motion-stagger
:bg-color="bgColor"
:tracking="props.resourcesData.tracking"
@click="handleVideoPlayClick"
/>

View File

@@ -3,9 +3,9 @@
<template>
<LayoutsHeader />
<main class="main-promotion relative">
<BlocksButtonHome />
<LayoutsEventNavigation />
<slot />
<LayoutsEventNavigation />
<BlocksButtonHome />
</main>
<LayoutsFooter />
</template>

View File

@@ -18,7 +18,7 @@ export default defineNuxtPlugin(async nuxtApp => {
try {
const url = `${dataResourcesUrl}/multilingual/${commonTranslations}`
const translations = await commonFetch('GET', url)
const translations = await commonFetch('GET', url, { loading: false })
if (!translations || typeof translations !== 'object') {
return

View File

@@ -0,0 +1,199 @@
import { animate, stagger } from 'motion-v'
import type { DOMKeyframesDefinition, AnimationOptions } from 'motion-v'
// 상수 정의
const ANIMATION_CONFIG = {
initial: {
opacity: 0,
translateY: '40px',
},
target: {
opacity: 1,
y: 0,
},
stagger: 0.1,
duration: 0.6,
easing: 'ease',
} as const
const INTERSECTION_CONFIG = {
threshold: 0.2,
} as const
const RETRY_DELAYS = [0, 100, 300] as const
// 타입 정의
type SectionElement = HTMLElement & { tagName: 'SECTION' }
export default defineNuxtPlugin(nuxtApp => {
// 전역 상태 관리
const animatedItems = new Set<HTMLElement>()
const sectionObservers = new Map<SectionElement, IntersectionObserver>()
const sectionItems = new Map<SectionElement, Set<HTMLElement>>()
/**
* 섹션의 motion-item들을 애니메이션
*/
const animateSectionItems = (section: SectionElement): void => {
const items = sectionItems.get(section)
if (!items?.size) return
const newItems = Array.from(items).filter(item => !animatedItems.has(item))
if (!newItems.length) return
// 애니메이션 실행
newItems.forEach(item => animatedItems.add(item))
animate(
newItems,
ANIMATION_CONFIG.target as DOMKeyframesDefinition,
{
delay: stagger(ANIMATION_CONFIG.stagger),
duration: ANIMATION_CONFIG.duration,
easing: ANIMATION_CONFIG.easing,
} as AnimationOptions
)
}
/**
* 섹션이 viewport에 있는지 확인
*/
const isSectionInViewport = (section: SectionElement): boolean => {
const rect = section.getBoundingClientRect()
const windowHeight =
window.innerHeight || document.documentElement.clientHeight
const visibleHeight =
Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0)
const sectionHeight = rect.height
return visibleHeight / sectionHeight >= INTERSECTION_CONFIG.threshold
}
/**
* 섹션에 IntersectionObserver 등록
*/
const observeSection = (section: SectionElement): void => {
if (sectionObservers.has(section)) return
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
animateSectionItems(section)
}
}
}, INTERSECTION_CONFIG)
observer.observe(section)
sectionObservers.set(section, observer)
}
/**
* 섹션에 아이템 등록 및 초기 애니메이션 체크
*/
const registerItem = (el: HTMLElement, section: SectionElement): void => {
// 섹션의 아이템 목록에 추가
if (!sectionItems.has(section)) {
sectionItems.set(section, new Set())
observeSection(section)
}
sectionItems.get(section)!.add(el)
// 이미 viewport에 있는 경우 즉시 체크 (여러 번 체크)
if (!isSectionInViewport(section)) return
// 즉시 실행 및 재확인
for (const delay of RETRY_DELAYS) {
if (delay === 0) {
requestAnimationFrame(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
})
} else {
setTimeout(() => {
if (isSectionInViewport(section)) {
animateSectionItems(section)
}
}, delay)
}
}
}
/**
* 섹션에서 아이템 제거 및 정리
*/
const unregisterItem = (el: HTMLElement, section: SectionElement): void => {
const items = sectionItems.get(section)
if (!items) return
items.delete(el)
animatedItems.delete(el)
// 섹션에 아이템이 없으면 observer 정리
if (items.size === 0) {
const observer = sectionObservers.get(section)
observer?.disconnect()
sectionObservers.delete(section)
sectionItems.delete(section)
}
}
/**
* 초기 스타일 설정
*/
const setInitialStyles = (el: HTMLElement): void => {
el.style.opacity = ANIMATION_CONFIG.initial.opacity.toString()
el.style.transform = `translateY(${ANIMATION_CONFIG.initial.translateY})`
}
/**
* 가장 가까운 section 찾기
*/
const findSection = (el: HTMLElement): SectionElement | null => {
const section = el.closest('section')
if (!section) {
if (import.meta.dev) {
// eslint-disable-next-line no-console
console.warn('[motion-stagger] No section found for element:', el)
}
return null
}
return section as SectionElement
}
// v-motion-stagger 디렉티브
nuxtApp.vueApp.directive('motion-stagger', {
mounted(el: HTMLElement) {
setInitialStyles(el)
const section = findSection(el)
if (!section) return
registerItem(el, section)
},
unmounted(el: HTMLElement) {
const section = el.closest('section') as SectionElement | null
if (!section) return
unregisterItem(el, section)
},
})
// 페이지 이동 시 정리
if (import.meta.client) {
nuxtApp.hook('page:finish', () => {
const currentSections = new Set<SectionElement>(
Array.from(document.querySelectorAll('section')) as SectionElement[]
)
for (const [section, observer] of sectionObservers.entries()) {
if (!currentSections.has(section)) {
observer.disconnect()
sectionObservers.delete(section)
sectionItems.delete(section)
}
}
})
}
})

View File

@@ -163,7 +163,7 @@ export default defineEventHandler(async event => {
event.context.gameDomain = gameDomain
} catch (error) {
// eslint-disable-next-line no-console
console.error('gameData load error:', error)
console.error('[API Error] gameData load error:', error)
}
if (gameDataResponse?.code === 0 && 'value' in gameDataResponse) {
@@ -344,6 +344,10 @@ export default defineEventHandler(async event => {
}
} else {
// ### 에러 응답 처리 -------------------------------------------------------------
// API 에러 코드를 명확하게 로깅하여 타입 에러와 구분
const apiErrorCode = gameDataResponse?.code
const apiErrorMessage = gameDataResponse?.message
// 언어 코드 추출 시도
let errorLocale = 'ko' // 기본값
try {
@@ -359,7 +363,7 @@ export default defineEventHandler(async event => {
}
// 91001 에러인 경우 바로 리다이렉트
if (gameDataResponse?.code === 91001) {
if (apiErrorCode === 91001) {
const errorPath = `/${errorLocale}/error`
event.node.res.statusCode = 302
event.node.res.setHeader('Location', errorPath)
@@ -369,8 +373,8 @@ export default defineEventHandler(async event => {
// 다른 에러는 기존대로 throw
throw createError({
statusCode: gameDataResponse?.code || 500,
statusMessage: gameDataResponse?.message,
statusCode: apiErrorCode || 500,
statusMessage: apiErrorMessage,
})
}
})

View File

@@ -18,7 +18,7 @@ export const useGameDataStore = defineStore('gameData', () => {
snsJson: null as GameDataValue['sns_json'] | null,
urlJson: null as GameDataValue['url_json'] | null,
marketJson: null as GameDataValue['market_json'] | null,
fontFamily: null as GameDataValue['game_font']['font_family'] | null,
fontFamily: null as GameDataValue['game_font_json']['font_family'] | null,
gnb: null as GameDataValue['gnb'] | null,
eventBanner: null as GameDataValue['event_banner'] | null,
})
@@ -42,7 +42,7 @@ export const useGameDataStore = defineStore('gameData', () => {
state.snsJson = data?.sns_json
state.urlJson = data?.url_json
state.marketJson = data?.market_json
state.fontFamily = data?.game_font?.font_family
state.fontFamily = data?.game_font_json?.font_family
state.gnb = data?.gnb
state.eventBanner = data?.event_banner
}

View File

@@ -132,6 +132,7 @@ export const useModalStore = defineStore('modalStore', () => {
// content ------------------
const content = {
storeIsOpen: ref(false),
storeIsShowDimmed: ref(true),
storeModalName: ref(''),
storeIsOutsideClose: ref(false),
storeContentTitle: ref(''),
@@ -140,12 +141,14 @@ export const useModalStore = defineStore('modalStore', () => {
}
const handleOpenContent = ({
isShowDimmed = true,
isOutsideClose = false,
modalName = '',
contentTitle,
tabInfo,
tabActiveIndex = 0,
}: ContentParams) => {
content.storeIsShowDimmed.value = isShowDimmed
content.storeIsOpen.value = true
content.storeModalName.value = modalName
content.storeIsOutsideClose.value = isOutsideClose

View File

@@ -424,7 +424,7 @@ const handleMoveFocus = (target: 'pc' | 'mobile') => {
<img
:src="
formatPathHost(
`/images/common/grades_driver/Type-${driver.driverCode}.svg`,
`/images/common/download_driver/Type-${driver.driverCode}.svg`,
{ imageType: 'common' }
)
"

View File

@@ -247,7 +247,7 @@ const handlePreregistClick = () => {
:resources-data="preSubTitleData"
class="title-sm mt-2"
/>
<div class="flex flex-col gap-4 mt-8 sm:flex-row">
<div v-motion-stagger class="flex flex-col gap-4 mt-8 sm:flex-row">
<div
v-if="preImgPreregistdData"
class="max-w-[336px] md:max-w-[446px]"
@@ -295,7 +295,10 @@ const handlePreregistClick = () => {
</ul>
</div>
</div>
<div class="flex gap-3 justify-center flex-wrap mt-8 md:gap-2.5">
<div
v-motion-stagger
class="flex gap-3 justify-center flex-wrap mt-8 md:gap-2.5"
>
<BlocksButtonLauncher
type="duplication"
platform="stove"
@@ -355,6 +358,7 @@ const handlePreregistClick = () => {
/>
<div
v-if="rewardImages.length"
v-motion-stagger
class="overflow-hidden w-[calc(100%+40px)] min-h-[228px] mt-6 mx-[-20px] sm:w-[calc(100%+80px)] sm:mx-[-40px] md:w-full md:min-h-[281px] md:mt-8 md:mx-auto"
>
<ClientOnly>

View File

@@ -50,20 +50,15 @@ const analytics = {
action_type: 'click',
click_sarea: props.pageVerTmplNameEn,
} as TrackingObject
const arrowsData: PageDataResourceGroups = [
{
tracking: {
...analytics,
click_item: '1. 컨텐츠 리스트 - 네비게이션(좌)',
},
const arrowsData: PageDataResourceGroups = ['좌', '우'].map(direction => ({
display: {
color_code: '#383838',
},
{
tracking: {
...analytics,
click_item: '1. 컨텐츠 리스트 - 네비게이션(우)',
},
tracking: {
...analytics,
click_item: `1. 컨텐츠 리스트 - 네비게이션(${direction})`,
},
]
}))
const recommendHover = ref(false)
@@ -291,21 +286,19 @@ const handleLoadMoreRecent = () => {
.splide {
@apply pb-[68px] sm:pb-[0];
}
.splide:deep(.splide__arrows) {
@apply block;
}
.splide:deep(.splide__arrows) .splide-arrow {
@apply block top-[unset] bottom-[20px] translate-y-0 bg-cover bg-center bg-no-repeat
@apply top-[unset] bottom-[20px] translate-y-0 bg-cover bg-center bg-no-repeat
sm:bottom-[24px] md:bottom-[36px] lg:bottom-[60px];
}
.splide:deep(.splide__arrows) .splide__arrow--prev {
@apply left-[20px] bg-[image:url('/images/common/btn_system_arrow_prev.png')]
@apply left-[20px]
sm:left-[calc(60.3%+21px)]
md:left-[calc(56%+39px)]
lg:left-[790px];
}
.splide:deep(.splide__arrows) .splide__arrow--next {
@apply right-[20px] bg-[image:url('/images/common/btn_system_arrow_next.png')]
@apply right-[20px]
sm:right-[28px]
md:right-[unset] md:left-[calc(56%+99px)]
lg:left-[850px];

View File

@@ -124,6 +124,7 @@ const getArticleUrl = (articleId: string) => {
<ClientOnly>
<WidgetsSlideDefault
v-if="slideLength > 0"
v-motion-stagger
v-bind="splideOptions"
:slide-item-length="slideLength"
:arrows-data="arrowsData"

View File

@@ -44,7 +44,7 @@ const buttonListData = computed(() => {
:resources-data="subTitleData"
class="title-sm max-w-[944px] mt-2"
/>
<div v-if="imgListData" class="img-container">
<div v-if="imgListData" v-motion-stagger class="img-container">
<div v-for="(item, index) in imgListData" :key="index" class="img-item">
<AtomsImg
v-if="getResourceSrc(item)"
@@ -71,9 +71,9 @@ const buttonListData = computed(() => {
<style scoped>
.img-container {
@apply flex flex-col items-center justify-center gap-4 box-content mx-auto mt-[32px]
w-[440px]
md:w-[944px];
@apply flex flex-col items-center justify-center gap-4 box-content mx-auto mt-[32px] w-full
max-w-[440px]
md:max-w-[944px];
}
.img-item {
@apply w-full;

View File

@@ -91,6 +91,7 @@ const getVideoSrc = (item: PageDataTemplateComponent) => {
/>
<AtomsVideo
v-if="hasComponentGroup(item, 'video')"
v-motion-stagger
:src="getVideoSrc(item)"
:play="currentSlideIndex === index"
class="aspect-[16/10] w-[258px] mt-8 md:w-[496px] md:mt-10"

View File

@@ -40,6 +40,9 @@ const arrowsData = computed(() => {
const paginationData = computed(() => {
return getComponentGroupAry(props.components, 'pagination')
})
const videoPlayData = computed(() => {
return getComponentGroup(props.components, 'videoPlay')
})
const getVideoPlayTracking = (item: string) => {
return {
@@ -89,6 +92,7 @@ onBeforeUnmount(() => {
class="title-md max-w-[944px]"
/>
<WidgetsSlideThumbnail
v-motion-stagger
:thumbnail-data="slideData"
:pagination-data="paginationData"
:drag="false"
@@ -115,6 +119,7 @@ onBeforeUnmount(() => {
<AtomsButtonPlay
v-if="playingSlideIndex !== index"
class="btn-play"
:bg-color="getColorCodeFromData(videoPlayData?.display, 'none')"
:tracking="getVideoPlayTracking(item?.group_label)"
@click="handleVideoClick(index)"
/>

View File

@@ -70,6 +70,7 @@ const handleSplideMove = (_splide: SplideType, newIndex: number) => {
/>
<WidgetsSlideCenterFocus
v-if="slideData"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData?.length"
:arrows-data="arrowsData"

View File

@@ -80,6 +80,7 @@ const handleSplideMove = (
/>
<WidgetsSlideCenterHighlight
v-if="slideData"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData?.length"
:arrows-data="arrowsData"

View File

@@ -101,6 +101,7 @@ const slideItemSize = {
<WidgetsVideoPlay v-if="videoPlayData" :resources-data="videoPlayData" />
<WidgetsSlideCenterHighlight
v-if="slideData && slideData.length > 0"
v-motion-stagger
:slide-item-size="slideItemSize"
:slide-item-length="slideData.length"
:arrows-data="arrowsData"

View File

@@ -32,24 +32,24 @@ export interface GameDataValue {
comm_sns_bg_color_json: {
display: ColorObject
}
comm_multilang_filename: string
comm_multilang_filename?: string
footer_dev_ci_img_yn: boolean
footer_dev_ci_img_path: string
default_lang_code?: string
game_font: GameDataGameFont
game_font_json?: GameDataGameFont
globals: GameDataGlobal[]
gnb: GameDataGnb
intro: GameDataIntro
inspection: Record<string, any>
stove_gnb_json: GameDataStoveGnb
favicon_json: GameDataImg
meta_tag_json: GameDataMetaTag
sns_json: GameDataSns
url_json: Record<string, string>
meta_tag_json?: GameDataMetaTag
sns_json?: GameDataSns
url_json?: Record<string, string>
footer_json: string // JSON 문자열로 변경
img_json: GameDataImg
img_json?: GameDataImg
market_json: Record<string, { url: string }>
event_banner: GameDataEventBanner
event_banner?: GameDataEventBanner
os_type: OsType
platform_type: PlatformType
}
@@ -119,6 +119,7 @@ export interface GameDataResourceGroupBtnInfo extends ColorObject {
disabled: boolean
txt_btn_name: string
detail: Record<string, any>
use_game_font: 0 | 1 // 0: 사용하지 않음, 1: 사용함
}
export interface GameDataResourceGroup {
@@ -162,6 +163,7 @@ export interface GameDataGnb {
lang_codes: string // JSON 문자열로 변경
buttons: GameDataButton[]
menus: GameDataMenuChildren
use_game_font: 0 | 1 // 0: 사용하지 않음, 1: 사용함
}
// 인트로 타입

View File

@@ -33,8 +33,8 @@ export interface OperateResourcesResponse {
}
export interface getOperateResourcesParams {
pageSeq: string
pageVer: string
pageSeq: number
pageVer: number
pageVerTmplSeq: number
langCode: string
q?: string

View File

@@ -25,20 +25,19 @@ export interface PageDataResponse {
// API 응답의 value 객체 타입
export interface PageDataValue {
page_seq: string
page_seq: number
page_type: number
page_name: string
page_name_en: string
page_ver: string
page_ver: number
is_login_required: number
meta_tag_type: number
fit_page_height: boolean
use_top_btn: boolean
use_sns_btn: boolean
use_lnb: boolean
lnb_text_color_code_active: string
lnb_text_color_code_deactive: string
lnb_menus: Record<string, PageDataLnbMenu>
lnb_text_color_code_active?: string
lnb_text_color_code_deactive?: string
lnb_menus?: Record<string, PageDataLnbMenu>
meta_tag_json: PageDataMetaTag
templates: Record<string, PageDataTemplate>
}
@@ -84,16 +83,16 @@ export interface PageDataResourceGroupResPath {
}
export interface PageDataResourceGroupBtnInfo extends ColorObject {
disabled: boolean
txt_btn_name: string
detail: Record<string, any>
use_game_font: 0 | 1
disabled?: boolean
use_game_font: 0 | 1 // 0: 사용하지 않음, 1: 사용함
}
// 리소스 그룹 타입
export interface PageDataResourceGroupDisplay extends ColorObject {
text: string
use_game_font: 0 | 1
text?: string
use_game_font?: 0 | 1 // 0: 사용하지 않음, 1: 사용함
}
export interface PageDataResourceGroup {
@@ -127,7 +126,6 @@ export type PageDataTemplateComponents =
| PageDataTemplateComponent // 단일 컴포넌트 패턴
| {
group_sets: PageDataTemplateComponentSet[]
arrow: PageDataArrowComponent
} // 그룹 세트 패턴
// 템플릿 타입
@@ -172,8 +170,3 @@ export interface PageDataApiResult {
data: PageDataResponse | null
error: string | null
}
// Arrow 컴포넌트 타입
export type PageDataArrowComponent = PageDataTemplateComponent & {
groups: PageDataResourceGroups
}

View File

@@ -22,6 +22,7 @@ export interface ToastParams {
}
export interface ContentParams {
isShowDimmed?: boolean
isOutsideClose?: boolean
modalName?: string
contentTitle: string

View File

@@ -55,8 +55,13 @@ const buildRequestOptions = (
let callerDetail = ''
if (import.meta.client) {
const gameDataStore = useGameDataStore()
stoveGameId = gameDataStore.gameData?.game_id || ''
try {
const gameDataStore = useGameDataStore()
stoveGameId = gameDataStore.gameData?.game_id || ''
} catch {
stoveGameId = ''
}
callerDetail = useCookie('sgs_da_uuid').value || ''
}
@@ -116,9 +121,12 @@ export const commonFetch = async (
options: FetchOptions = {}
): Promise<any> => {
let loadingStore = null
if (import.meta.client) {
loadingStore = useLoadingStore()
try {
loadingStore = useLoadingStore()
} catch {
loadingStore = null
}
}
startLoading(loadingStore, options.loading)

View File

@@ -158,7 +158,7 @@ export const ssrGetFinalLocale = (
}
}
// 3. 서비스 기본 언어
// 4. 서비스 기본 언어
finalLocale = defaultLocale
} catch (e) {
finalLocale = defaultLocale

View File

@@ -31,7 +31,7 @@ const getColorCode = ({
* @returns 색상 값
*/
export const getColorCodeFromData = (
data: ColorObject,
data: ColorObject | undefined,
type: 'btn' | 'txt' | 'none' = 'txt'
) => {
const suffixMap: Record<'btn' | 'txt' | 'none', string> = {

View File

@@ -53,6 +53,7 @@ export default defineNuxtConfig({
'@nuxtjs/tailwindcss',
'nuxt-gtag',
'@nuxtjs/device',
'motion-v/nuxt',
],
extends: ['./layers'],
alias: {

View File

@@ -27,7 +27,7 @@
"@amplitude/analytics-browser": "^2.24.0",
"@amplitude/analytics-node": "^1.5.9",
"@nuxtjs/device": "^3.2.4",
"@nuxtjs/i18n": "^10.0.6",
"@nuxtjs/i18n": "^9.0.0",
"@pinia/nuxt": "^0.6.1",
"@seed-next/date": "^0.0.0",
"@splidejs/splide": "^4.1.4",
@@ -36,6 +36,7 @@
"@vueuse/core": "^13.6.0",
"@vueuse/nuxt": "^13.6.0",
"h3": "^1.15.4",
"motion-v": "^1.8.1",
"nuxt": "^4.0.3",
"nuxt-gtag": "^4.0.0",
"pinia": "^2.3.1",

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -11,7 +11,6 @@ export default {
md: '1024px', // PC: 1024px ~ 1439px
lg: '1440px', // Large PC: 1440px+
},
spacing: {},
colors: {
'theme-foreground': 'var(--foreground)',
'theme-foreground-10': 'var(--foreground-10)',
@@ -26,6 +25,9 @@ export default {
'theme-foreground-gray-750': 'var(--foreground-gray-750)',
'theme-foreground-gray-500': 'var(--foreground-gray-500)',
},
transitionTimingFunction: {
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
},
},
},
} satisfies Config