Merge branch 'feature/20250206_all' of https://git.sginfra.net/sgp-web-d/web-template-fe into feature/20250206_all
@@ -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)
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -38,8 +38,4 @@
|
||||
.type-full .splide__arrow--next {
|
||||
@apply right-10;
|
||||
}
|
||||
|
||||
.splide-arrow svg {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''"
|
||||
/>
|
||||
|
||||
31
layers/components/atoms/icons/ArrowControlTopLine.vue
Normal 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>
|
||||
30
layers/components/atoms/icons/ArrowRightFill.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -53,6 +53,7 @@ watch(
|
||||
:class="[
|
||||
'modal-wrap',
|
||||
{ 'is-open': content.storeIsOpen },
|
||||
{ dimmed: content.storeIsShowDimmed },
|
||||
content.storeModalName,
|
||||
]"
|
||||
@click="handleOutsideClick"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2>
|
||||
<h2 v-motion-stagger>
|
||||
<BlocksVisualContent :resources-data="props.resourcesData" />
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<template>
|
||||
<LayoutsHeader />
|
||||
<main class="main-promotion relative">
|
||||
<BlocksButtonHome />
|
||||
<LayoutsEventNavigation />
|
||||
<slot />
|
||||
<LayoutsEventNavigation />
|
||||
<BlocksButtonHome />
|
||||
</main>
|
||||
<LayoutsFooter />
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
199
layers/plugins/motion-stagger.client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: 사용함
|
||||
}
|
||||
|
||||
// 인트로 타입
|
||||
|
||||
@@ -33,8 +33,8 @@ export interface OperateResourcesResponse {
|
||||
}
|
||||
|
||||
export interface getOperateResourcesParams {
|
||||
pageSeq: string
|
||||
pageVer: string
|
||||
pageSeq: number
|
||||
pageVer: number
|
||||
pageVerTmplSeq: number
|
||||
langCode: string
|
||||
q?: string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ToastParams {
|
||||
}
|
||||
|
||||
export interface ContentParams {
|
||||
isShowDimmed?: boolean
|
||||
isOutsideClose?: boolean
|
||||
modalName?: string
|
||||
contentTitle: string
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -158,7 +158,7 @@ export const ssrGetFinalLocale = (
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 서비스 기본 언어
|
||||
// 4. 서비스 기본 언어
|
||||
finalLocale = defaultLocale
|
||||
} catch (e) {
|
||||
finalLocale = defaultLocale
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -53,6 +53,7 @@ export default defineNuxtConfig({
|
||||
'@nuxtjs/tailwindcss',
|
||||
'nuxt-gtag',
|
||||
'@nuxtjs/device',
|
||||
'motion-v/nuxt',
|
||||
],
|
||||
extends: ['./layers'],
|
||||
alias: {
|
||||
|
||||
@@ -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
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -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
|
||||
|
||||