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

This commit is contained in:
“hyeonggkim”
2025-11-13 18:49:48 +09:00
41 changed files with 807 additions and 289 deletions

View File

@@ -45,6 +45,6 @@ const componentProps = computed(() => {
w-[40px] h-[40px] md:w-[48px] md:h-[48px]
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
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-full after:opacity-0 after:transition-all after:duration-300 after:ease-in-out
hover:after:opacity-10;
hover:after:opacity-10;
}
</style>

View File

@@ -37,7 +37,7 @@ const onInput = (event: Event) => {
:type="typeof $attrs.type === 'string' ? $attrs.type : 'text'"
:placeholder="props.placeholder"
v-bind="$attrs"
class="relative w-full h-[48px] px-[12px] outline-none border border-solid border-[1px] border-[#D9D9D9] rounded-[8px] bg-white text-left text-[#333333] text-[14px] font-[400] leading-[20px] tracking-[-0.42px] placeholder:text-[#B2B2B2] md:h-[56px] md:px-[16px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px] hover:[&:not([readonly])]:border-[#999999] focus:border-[#999999]"
class="relative w-full h-[48px] px-[12px] outline-none border-solid border-[1px] border-[#D9D9D9] rounded-[8px] bg-white text-left text-[#333333] text-[14px] font-[400] leading-[20px] tracking-[-0.42px] placeholder:text-[#B2B2B2] md:h-[56px] md:px-[16px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px] hover:[&:not([readonly])]:border-[#999999] focus:border-[#999999]"
@input="onInput"
@keydown="emit('keydown', $event)"
/>

View File

@@ -3,27 +3,26 @@ import { useLoadingStore } from '#layers/stores/useLoadingStore'
const loadingStore = useLoadingStore()
const { fullLoading } = storeToRefs(loadingStore)
</script>
<template>
<Transition name="fade">
<div v-if="fullLoading" class="spinner-container">
<Transition name="fade-out">
<div v-show="fullLoading" class="spinner-wrap">
<div class="spinner"></div>
</div>
</Transition>
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 bg-black/90 flex items-center justify-center z-[900];
.spinner-wrap {
@apply fixed inset-0 bg-black pt-[96px] flex items-center justify-center sm:pt-[112px] z-[150];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner-container {
.spinner-wrap {
@apply bg-white/90;
}
.spinner {

View File

@@ -15,7 +15,7 @@ const canTeleport = (localId: string) => {
<template v-for="[localId, loadingInfo] in localLoadings" :key="localId">
<Teleport v-if="canTeleport(localId)" :to="`#${localId}`">
<Transition name="fade">
<div v-if="loadingInfo.active" class="spinner-container">
<div v-if="loadingInfo.active" class="spinner-wrap">
<div class="spinner"></div>
</div>
</Transition>
@@ -24,8 +24,8 @@ const canTeleport = (localId: string) => {
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 flex items-center justify-center z-[900];
.spinner-wrap {
@apply absolute inset-0 flex items-center justify-center z-[5];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
interface Props {
isLoading?: boolean
}
const { isLoading = false } = defineProps<Props>()
</script>
<template>
<div v-if="isLoading" class="spinner-container">
<div class="spinner"></div>
</div>
</template>
<style scoped>
.spinner-container {
@apply absolute inset-0 flex items-center justify-center z-[90];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner {
@apply bg-[url('/images/common/publisning_template_loader_white.png')];
}
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
const showSnsList = ref(false)
const { tm } = useI18n()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
@@ -33,9 +34,9 @@ const handleCopy = async () => {
try {
const url = window.location.href
await navigator.clipboard.writeText(url)
handleOpenToast('복사 성공')
handleOpenToast(tm('Alert_Copy_Complete'))
} catch (error) {
console.error('복사 실패:', error)
console.error('[handleCopy] Error:', error)
}
}
</script>

View File

@@ -58,10 +58,6 @@ const handlePagination = (page: number) => {
currentPage.value = page
emits('update:page', page)
}
onMounted(() => {
console.log(blocks.value)
})
</script>
<template>

View File

@@ -80,16 +80,3 @@ onBeforeUnmount(() => {
<template>
<div id="stove-wrap" class="relative h-[48px] z-[5]" />
</template>
<style scoped>
[data-theme='light'] {
#stove-wrap {
@apply bg-white;
}
}
[data-theme='dark'] {
#stove-wrap {
@apply bg-black;
}
}
</style>

View File

@@ -1,13 +1,7 @@
<script setup lang="ts">
interface props {
isShowDimmed?: boolean
contentText?: string
confirmButtonText?: string
isOutsideClose?: boolean
modalName?: string
}
import type { DialogParams } from '#layers/types/components/modal'
const props = withDefaults(defineProps<props>(), {
const props = withDefaults(defineProps<DialogParams>(), {
isShowDimmed: true,
isOutsideClose: false,
})

View File

@@ -1,14 +1,7 @@
<script setup lang="ts">
interface props {
isShowDimmed?: boolean
contentText?: string
confirmButtonText?: string
cancelButtonText?: string
isOutsideClose?: boolean
modalName?: string
}
import type { DialogParams } from '#layers/types/components/modal'
const props = withDefaults(defineProps<props>(), {
const props = withDefaults(defineProps<DialogParams>(), {
isShowDimmed: true,
isOutsideClose: false,
})

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { ContentParams } from '#layers/types/components/modal'
interface TabItem {
title: string
desc: string
}
const props = withDefaults(defineProps<ContentParams>(), {
isOutsideClose: false,
contentTitle: '',
tabActiveIndex: 0,
tabInfo: () => [],
})
const modalStore = useModalStore()
const breakpoints = useResponsiveBreakpoints()
const isOpen = defineModel<boolean>('isOpen', { default: false })
const currentTab = ref<number>(props.tabActiveIndex)
const responsiveTransition = computed(() =>
breakpoints.value.isXs ? 'slide-up' : 'fade'
)
const tabInfo = computed<TabItem[]>(() => props.tabInfo ?? [])
const isTab = computed(() => tabInfo.value.length >= 2)
const isVisible = (index: number) => currentTab.value === index
const handleCloseModal = () => {
isOpen.value = false
}
const handleOutsideClick = () => {
if (props.isOutsideClose) handleCloseModal()
}
const handleUpdateTab = (tabNumber: number) => {
if (currentTab.value !== tabNumber) {
currentTab.value = tabNumber
}
}
watch(isOpen, newVal => {
if (newVal) {
modalStore.handleControlDimmed(true)
} else {
modalStore.handleControlDimmed(false)
}
})
</script>
<template>
<Transition :name="responsiveTransition">
<div
v-if="isOpen"
:class="['modal-wrap', { 'is-open': isOpen }, props.modalName]"
@click="handleOutsideClick"
>
<div class="modal-area" @click.stop>
<div class="modal-header">
<strong class="title">{{ props.contentTitle }}</strong>
<button type="button" class="modal-close" @click="handleCloseModal">
<span class="sr-only">close</span>
<AtomsIconsCloseLine size="24" color="#333333" />
</button>
</div>
<div class="modal-body">
<template v-if="isTab">
<div class="tab-trigger" role="tablist">
<template
v-for="(tab, index) in tabInfo"
:key="tab.title + index"
>
<button
type="button"
:class="['btn-trigger', { 'is-active': isVisible(index) }]"
role="tab"
@click="handleUpdateTab(index)"
>
{{ tab.title }}
</button>
</template>
</div>
<!-- 패널 겹침(컨테이너를 가장 패널 높이에 맞춤) -->
<div class="tab-panel grid">
<template v-for="(tab, index) in tabInfo" :key="tab.desc + index">
<div
v-dompurify-html="tab.desc"
:class="[
'content-tex',
'use-base',
{ 'is-visible': isVisible(index) },
]"
/>
</template>
</div>
</template>
<template v-else>
<div
v-dompurify-html="tabInfo[0]?.desc"
class="content-tex use-base"
/>
</template>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.modal-wrap {
@apply overflow-hidden flex-col p-0 pt-[80px] sm:p-5;
}
.modal-area {
@apply overflow-hidden flex flex-col rounded-t-[20px] sm:w-[560px] sm:max-h-[680px] sm:rounded-b-[20px];
}
.modal-header {
@apply flex items-center justify-between gap-[8px] w-full py-[16px] px-[20px]
sm:pt-[20px] sm:px-[32px];
}
.modal-header .title {
@apply line-clamp-2 w-full text-[#1F1F1F] text-[16px] font-[700] leading-[24px] tracking-[-0.48px];
}
.modal-body {
@apply flex flex-col flex-1 min-h-0;
}
.tab-trigger {
@apply relative flex w-full mb-[12px] sm:mb-[24px];
}
.btn-trigger {
@apply relative w-full py-2.5 before:content-[''] before:absolute before:bottom-0 before:left-0 before:w-full before:h-[1px] before:bg-[rgba(0,0,0,0.15)] text-[#1F1F1F] text-[14px] font-[500] leading-[24px] tracking-[-0.42px];
}
.btn-trigger.is-active {
@apply before:bg-[#1F1F1F] before:h-[2px];
}
.tab-panel {
@apply overflow-hidden grid w-full h-full;
}
.tab-panel .content-tex {
@apply col-start-1 row-start-1 transition-opacity duration-200 ease-in-out;
}
.tab-panel .content-tex.is-hidden {
@apply opacity-0 invisible pointer-events-none;
}
.content-tex {
@apply overflow-y-auto mb-4 px-6 sm:mb-6 sm:px-8;
}
.content-tex::-webkit-scrollbar-track {
@apply bg-transparent;
}
.content-tex::-webkit-scrollbar {
@apply w-5;
}
.content-tex::-webkit-scrollbar-thumb {
@apply w-1 bg-[#D9D9D9] rounded-full bg-clip-padding border-solid border-transparent border-8;
}
</style>

View File

@@ -6,16 +6,14 @@ const { toast } = modalStore
<template>
<Transition name="fade">
<div v-if="toast.storeIsOpen" class="toast-container">
<p class="toast-text">
{{ toast.storeContentText }}
</p>
<p v-dompurify-html="toast.storeContentText" class="toast-text"></p>
</div>
</Transition>
</template>
<style scoped>
.toast-container {
@apply fixed left-1/2 max-w-[328px] py-3 px-6 rounded-[8px] bg-[rgba(85,85,85,0.4)] backdrop-blur-[25px] -translate-x-1/2 bottom-[20px] md:bottom-[40px] z-[800]
@apply fixed left-1/2 max-w-[328px] py-3 px-6 rounded-[8px] bg-[rgba(85,85,85,0.4)] backdrop-blur-[25px] -translate-x-1/2 bottom-[20px] md:bottom-[40px] z-[900]
before:content-[''] before:absolute before:inset-0 before:border before:border-white/10 before:rounded-[8px];
}
.toast-text {

View File

@@ -1,13 +1,8 @@
<script setup lang="ts">
import { getYouTubeEmbedUrl } from '@/layers/utils/youtubeUtil'
import type { YoutubeParams } from '#layers/types/components/modal'
interface Props {
youtubeUrl: string
isOutsideClose?: boolean
modalName?: string
}
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<YoutubeParams>(), {
youtubeUrl: '',
isOutsideClose: false,
})

View File

@@ -12,7 +12,6 @@ const { isPassedStoveGnb } = storeToRefs(scrollStore)
const isEventNavigationOpen = ref(true)
const eventNavigationList = ref<Record<string, EventNavigation>>({})
// const pinToMain = inject('pinToMain')
const getEventNavigation = async (): Promise<Record<
string,
@@ -30,7 +29,6 @@ const getEventNavigation = async (): Promise<Record<
const response = (await commonFetch('GET', apiUrl, {
query: queryParams,
loading: false,
})) as EventNavigationResponse | null
if (response?.code === 0 && 'value' in response) {

View File

@@ -6,12 +6,17 @@ import type {
GameDataMenuChildren,
GameDataResourceGroup,
GameDataResourceGroupSet,
PlatformTransformType,
} from '#layers/types/api/gameData'
const route = useRoute()
const { tm } = useI18n()
const { width } = useWindowSize()
const device = useDevice()
const gameDataStore = useGameDataStore()
const scrollStore = useScrollStore()
const breakpoints = useResponsiveBreakpoints()
const modalStore = useModalStore()
const { gameData } = storeToRefs(gameDataStore)
const { isPassedStoveGnb } = storeToRefs(scrollStore)
@@ -33,6 +38,12 @@ const gnb2depthButtonData = computed(
() => gnbData?.buttons[1]?.button_json as GameDataResourceGroupSet
)
const currentPath = computed(() => formatPathWithoutLocale(route.path))
const supportedPlatforms = computed(
() =>
getSupportedPlatforms(gameData.value?.os_type, {
platformType: gameData.value?.platform_type,
}) as PlatformTransformType[]
)
const pathMatches = (base: string, current: string) => {
if (!base || base === '/') return current === '/'
@@ -52,13 +63,11 @@ const hasActiveChild = (children?: GameDataMenuChildren) => {
const isNavItemActive = (gnbItem: GameDataMenu): boolean => {
const cur = currentPath.value
const base = gnbItem?.url_path
if(import.meta.client) {
const selfActive =
!!base &&
isInternalUrl(base) &&
pathMatches(formatPathWithoutLocale(base), cur)
return selfActive || hasActiveChild(gnbItem.children)
}
const selfActive =
!!base &&
isInternalUrl(base) &&
pathMatches(formatPathWithoutLocale(base), cur)
return selfActive || hasActiveChild(gnbItem.children)
}
// navAreaRef의 넓이를 구하는 함수
@@ -151,6 +160,57 @@ const has2depthButton = (gnbItem: GameDataMenu) => {
return gnbItem.children && Object.keys(gnbItem.children).length > 0
}
const highlight = (text: string) => `<span class="highlight">${text}</span>`
const PLATFORM_LABEL_KEY: Record<PlatformTransformType, string> = {
pc: 'PC',
google_play: 'Google Play',
app_store: 'App Store',
}
const tmWithGameName = (key: string): string => {
const raw = tm(key)
if (typeof raw !== 'string') return ''
const withName = raw.replace(
/%게임명%/g,
highlight(gameData.value?.game_name || '')
)
const platformLines = supportedPlatforms.value
.map(platform => highlight(PLATFORM_LABEL_KEY[platform] as string))
.filter(Boolean)
return platformLines.length
? `${withName}<br><br>${platformLines.join('<br>')}`
: withName
}
const showNotSupportedOSAlert = () => {
return modalStore.handleOpenAlert({
contentText: tmWithGameName('Alert_Not_SupportedOS'),
})
}
const handleStartClick = () => {
if (breakpoints.value.isDesktop) return
const target = device.isAndroid
? 'google_play'
: device.isApple
? 'app_store'
: null
if (!target || !supportedPlatforms.value.includes(target)) {
return showNotSupportedOSAlert()
}
const url = gameData.value?.market_json?.[target]?.url || ''
if (!url) return showNotSupportedOSAlert()
window.open(url, '_blank')
}
const stopClickOutside = onClickOutside(navAreaRef, () => handleMenuClose())
// 화면 크기 변경 시 오버플로우 재계산
@@ -319,33 +379,37 @@ onBeforeUnmount(() => {
</div>
</nav>
<div ref="startRef" class="btn-start">
<BlocksButtonLauncher
type="custom"
platform="pc"
:background-color="
getColorCode({
colorName: gnb1depthButtonData?.btn_info?.color_name_btn,
colorCode: gnb1depthButtonData?.btn_info?.color_code_btn,
})
"
:text-color="
getColorCode({
colorName: gnb1depthButtonData?.btn_info?.color_name_txt,
colorCode: gnb1depthButtonData?.btn_info?.color_code_txt,
})
"
>
{{ gnb1depthButtonData?.btn_info?.txt_btn_name }}
</BlocksButtonLauncher>
<div v-if="gnb2depthButtonData" class="nav-2depth hidden md:block">
<ul>
<li v-for="(item, key) in gnb2depthButtonData" :key="key">
<BlocksButtonLauncher type="custom" :platform="key">
{{ item.btn_info?.txt_btn_name }}
</BlocksButtonLauncher>
</li>
</ul>
</div>
<ClientOnly>
<component
:is="
breakpoints.isDesktop ? 'BlocksButtonLauncher' : 'AtomsButton'
"
type="custom"
platform="pc"
:background-color="
getColorCodeFromData(gnb1depthButtonData?.btn_info, 'btn')
"
:text-color="
getColorCodeFromData(gnb1depthButtonData?.btn_info, 'txt')
"
@click="handleStartClick"
>
{{ gnb1depthButtonData?.btn_info?.txt_btn_name }}
</component>
<div
v-if="breakpoints.isDesktop && gnb2depthButtonData"
class="nav-2depth"
>
<ul>
<li v-for="(item, key) in gnb2depthButtonData" :key="key">
<BlocksButtonLauncher type="custom" :platform="key">
{{ item.btn_info?.txt_btn_name }}
</BlocksButtonLauncher>
</li>
</ul>
</div>
</ClientOnly>
</div>
<button class="btn-close" @click="handleMenuClose">
<AtomsIconsCloseLine
@@ -363,7 +427,7 @@ onBeforeUnmount(() => {
<style scoped>
.header {
@apply bg-theme-foreground text-theme-foreground-reversal relative z-[100];
@apply bg-theme-foreground text-theme-foreground-reversal relative z-[200];
}
.game-wrap {
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[52px] bg-theme-foreground sm:px-[72px] md:h-16 md:pl-0 md:pr-[40px]

View File

@@ -12,14 +12,24 @@ interface Props {
const props = defineProps<Props>()
const { locale } = useI18n()
const { getTemplateComponent } = useTemplateRegistry()
const mainContentRef = ref<HTMLElement>()
const pinToMain = inject('pinToMain')
const { locale } = useI18n()
const { height: viewportH } = useWindowSize()
const { bottom: mainBottom } = useElementBounding(mainContentRef)
const { getTemplateComponent } = useTemplateRegistry()
const loadingStore = useLoadingStore()
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(
@@ -50,10 +60,7 @@ const setupSeoMeta = (metaTag: PageDataMetaTag) => {
})
}
onMounted(() => {
const { sendLog } = useAnalytics()
// sendLog(locale.value, useAnalyticsLogDataDirect('view', 1))
})
provide('pinToMain', pinToMain)
// 메타 태그 설정 감시
watchEffect(() => {
@@ -61,10 +68,25 @@ watchEffect(() => {
setupSeoMeta(props.pageData?.meta_tag_json)
}
})
watch(isPAssApiLoading, newVal => {
if (newVal) {
loadingStore.stopFullLoading()
}
})
onMounted(() => {
const { sendLog } = useAnalytics()
sendLog(locale.value, useAnalyticsLogDataDirect('view', 1))
if (!hasApiCallStarted.value) {
loadingStore.stopFullLoading()
}
})
</script>
<template>
<div class="main-content">
<div ref="mainContentRef" class="main-content">
<template
v-for="(template, index) in visibleTemplates"
:key="template.template_code ?? index"
@@ -76,22 +98,22 @@ watchEffect(() => {
:page-ver-tmpl-seq="template.page_ver_tmpl_seq"
/>
</template>
<ClientOnly>
<BlocksLnb v-if="isShowLnb" />
<div
v-if="isShowTopBtn || isShowSnsBtn"
:class="['utile-wrap', { 'is-stop': pinToMain }]"
>
<BlocksButtonScrollTop v-if="isShowTopBtn" />
<BlocksButtonSns v-if="isShowSnsBtn" />
</div>
</ClientOnly>
</div>
<ClientOnly>
<BlocksLnb v-if="isShowLnb" />
<div
v-if="isShowTopBtn || isShowSnsBtn"
:class="['utile-wrap', { 'is-stop': pinToMain }]"
>
<BlocksButtonScrollTop v-if="isShowTopBtn" />
<BlocksButtonSns v-if="isShowSnsBtn" />
</div>
</ClientOnly>
</template>
<style scoped>
.main-content {
@apply relative min-h-[200px] pt-[48px] md:min-h-[800px] md:pt-[64px];
@apply relative pt-[48px] md:pt-[64px];
}
.utile-wrap {
@apply fixed flex flex-col z-[100]

View File

@@ -24,26 +24,12 @@ const getBtnType = (item?: PageDataResourceGroupBtnInfo): ButtonType => {
return 'action'
}
const getBgColor = (item?: PageDataResourceGroupBtnInfo): string =>
getColorCode({
colorName: item?.color_name_btn,
colorCode: item?.color_code_btn,
})
const getTextColor = (item?: PageDataResourceGroupBtnInfo): string =>
getColorCode({
colorName: item?.color_name_txt,
colorCode: item?.color_code_txt,
})
const handleLogClick = (button: PageDataResourceGroup) => {
sendLog(locale.value, useAnalyticsLogDataDirect(button, props.pageVerTmplSeq))
if (button.btn_info?.detail?.btn_type === 'POP') {
const popupSize = button.btn_info?.detail?.size_info
const popupTitle = button.btn_info?.detail?.title
const popupContent = button.btn_info?.detail?.tab_info[0].title
modalStore.handleOpenAlert({
contentText: popupContent,
modalStore.handleOpenContent({
contentTitle: button.btn_info?.detail?.title,
tabInfo: button.btn_info?.detail?.tab_info,
})
}
}
@@ -62,8 +48,8 @@ const buttonList = computed(() => props.resourcesData || [])
v-if="button.btn_info?.detail?.btn_type === 'RUN'"
type="duplication"
:platform="button.btn_info?.detail?.market_type"
:background-color="getBgColor(button.btn_info)"
:text-color="getTextColor(button.btn_info)"
:background-color="getColorCodeFromData(button.btn_info, 'btn')"
:text-color="getColorCodeFromData(button.btn_info, 'txt')"
:disabled="button?.btn_info?.disabled"
@click="handleLogClick(button)"
>
@@ -75,8 +61,8 @@ const buttonList = computed(() => props.resourcesData || [])
:href="button.btn_info?.detail?.action?.url"
:target="button.btn_info?.detail?.action?.link_target"
:rel="button.btn_info?.detail?.action?.rel"
:background-color="getBgColor(button.btn_info)"
:text-color="getTextColor(button.btn_info)"
:background-color="getColorCodeFromData(button.btn_info, 'btn')"
:text-color="getColorCodeFromData(button.btn_info, 'txt')"
:disabled="button?.btn_info?.disabled"
@click="handleLogClick(button)"
>