Merge branch 'feature/202501107-all' into feature/20251001-gil
This commit is contained in:
12
app/app.vue
12
app/app.vue
@@ -15,7 +15,7 @@ const scrollStore = useScrollStore()
|
||||
|
||||
const { setGameData } = gameDataStore
|
||||
const { gameData } = storeToRefs(gameDataStore)
|
||||
const { youtube, confirm, alert, toast, handleResetYoutube } = modalStore
|
||||
const { youtube, confirm, alert, content, handleResetYoutube } = modalStore
|
||||
const { scrollGnbPosition } = storeToRefs(scrollStore)
|
||||
|
||||
const metaData = ref<GameDataMetaTag | null>(null)
|
||||
@@ -154,8 +154,16 @@ onBeforeUnmount(() => {
|
||||
<h1 class="sr-only">{{ gameData?.game_name }}</h1>
|
||||
<NuxtPage />
|
||||
|
||||
<WidgetsModalClient />
|
||||
<!-- 공통 모달 컴포넌트 -->
|
||||
<WidgetsModalClient />
|
||||
<BlocksModalContent
|
||||
v-model:is-open="content.storeIsOpen"
|
||||
:is-outside-close="content.storeIsOutsideClose"
|
||||
:content-title="content.storeContentTitle"
|
||||
:tab-info="content.storeTabInfo"
|
||||
:tab-active-index="content.storeTabActiveIndex"
|
||||
:modal-name="content.storeModalName"
|
||||
/>
|
||||
<BlocksModalYouTube
|
||||
v-model:is-open="youtube.storeIsOpen"
|
||||
:youtube-url="youtube.storeYoutubeUrl"
|
||||
|
||||
@@ -13,7 +13,6 @@ onMounted(() => {
|
||||
|
||||
const currentLayout = computed(() => getLayoutType(getPageData.value))
|
||||
|
||||
|
||||
definePageMeta({
|
||||
layout: false, // 동적 레이아웃을 위해 기본 레이아웃 비활성화
|
||||
middleware: ['inspection'],
|
||||
@@ -22,6 +21,6 @@ definePageMeta({
|
||||
|
||||
<template>
|
||||
<NuxtLayout :name="currentLayout">
|
||||
<LayoutsMain v-if="getPageData" id="page-content" :page-data="getPageData" />
|
||||
<LayoutsMain v-if="getPageData" :page-data="getPageData" />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@import './components/_layout.css';
|
||||
@import './components/_modal.css';
|
||||
@import './components/_splide.css';
|
||||
@import './components/_base-content.css';
|
||||
|
||||
@import '@splidejs/vue-splide/css';
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/* page-fade */
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.6s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
.page-fade-enter-from,
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* fade */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@@ -17,3 +7,35 @@
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* fade-out */
|
||||
.fade-out-enter-active,
|
||||
.fade-out-enter-from,
|
||||
.fade-out-enter-to {
|
||||
transition: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.fade-out-leave-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
.fade-out-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
.fade-out-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition:
|
||||
transform 0.3s ease-out,
|
||||
opacity 0.3s ease-out;
|
||||
}
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
93
layers/assets/css/components/_base-content.css
Normal file
93
layers/assets/css/components/_base-content.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* 에디터 콘텐츠 기본 스타일 유틸리티 */
|
||||
/* use-base 클래스를 추가하면 하위 자식 요소들에 기본 스타일이 적용됩니다 */
|
||||
@layer components {
|
||||
.use-base ul,
|
||||
.use-base ol {
|
||||
@apply list-disc pl-6;
|
||||
}
|
||||
.use-base ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
.use-base ul ul,
|
||||
.use-base ol ol,
|
||||
.use-base ul ol,
|
||||
.use-base ol ul {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.use-base table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
.use-base thead {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
.use-base th,
|
||||
.use-base td {
|
||||
@apply border border-gray-300 px-4 py-2 text-left;
|
||||
}
|
||||
.use-base th {
|
||||
@apply font-semibold bg-gray-50;
|
||||
}
|
||||
.use-base tbody tr:nth-child(even) {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
.use-base blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 italic text-gray-700;
|
||||
}
|
||||
|
||||
.use-base h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
.use-base h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
.use-base h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
.use-base h4 {
|
||||
@apply text-base;
|
||||
}
|
||||
.use-base h5 {
|
||||
@apply text-sm;
|
||||
}
|
||||
.use-base h6 {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.use-base strong,
|
||||
.use-base b {
|
||||
@apply font-bold;
|
||||
}
|
||||
.use-base em,
|
||||
.use-base i {
|
||||
@apply italic;
|
||||
}
|
||||
.use-base u {
|
||||
@apply underline;
|
||||
}
|
||||
.use-base s {
|
||||
@apply line-through;
|
||||
}
|
||||
|
||||
.use-base a {
|
||||
@apply text-blue-600 underline;
|
||||
}
|
||||
.use-base a:hover {
|
||||
@apply text-blue-800;
|
||||
}
|
||||
|
||||
.use-base img {
|
||||
@apply max-w-full h-auto my-4;
|
||||
}
|
||||
|
||||
.use-base pre {
|
||||
@apply bg-gray-100 p-4 rounded overflow-x-auto mb-4;
|
||||
}
|
||||
.use-base code {
|
||||
@apply bg-gray-100 px-1 py-0.5 rounded text-sm;
|
||||
}
|
||||
.use-base pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
}
|
||||
93
layers/assets/css/components/_baseContent.css
Normal file
93
layers/assets/css/components/_baseContent.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* 에디터 콘텐츠 기본 스타일 유틸리티 */
|
||||
/* use-base 클래스를 추가하면 하위 자식 요소들에 기본 스타일이 적용됩니다 */
|
||||
@layer components {
|
||||
.use-base ul,
|
||||
.use-base ol {
|
||||
@apply list-disc pl-6;
|
||||
}
|
||||
.use-base ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
.use-base ul ul,
|
||||
.use-base ol ol,
|
||||
.use-base ul ol,
|
||||
.use-base ol ul {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.use-base table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
.use-base thead {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
.use-base th,
|
||||
.use-base td {
|
||||
@apply border border-gray-300 px-4 py-2 text-left;
|
||||
}
|
||||
.use-base th {
|
||||
@apply font-semibold bg-gray-50;
|
||||
}
|
||||
.use-base tbody tr:nth-child(even) {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
.use-base blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 italic text-gray-700;
|
||||
}
|
||||
|
||||
.use-base h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
.use-base h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
.use-base h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
.use-base h4 {
|
||||
@apply text-base;
|
||||
}
|
||||
.use-base h5 {
|
||||
@apply text-sm;
|
||||
}
|
||||
.use-base h6 {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.use-base strong,
|
||||
.use-base b {
|
||||
@apply font-bold;
|
||||
}
|
||||
.use-base em,
|
||||
.use-base i {
|
||||
@apply italic;
|
||||
}
|
||||
.use-base u {
|
||||
@apply underline;
|
||||
}
|
||||
.use-base s {
|
||||
@apply line-through;
|
||||
}
|
||||
|
||||
.use-base a {
|
||||
@apply text-blue-600 underline;
|
||||
}
|
||||
.use-base a:hover {
|
||||
@apply text-blue-800;
|
||||
}
|
||||
|
||||
.use-base img {
|
||||
@apply max-w-full h-auto my-4;
|
||||
}
|
||||
|
||||
.use-base pre {
|
||||
@apply bg-gray-100 p-4 rounded overflow-x-auto mb-4;
|
||||
}
|
||||
.use-base code {
|
||||
@apply bg-gray-100 px-1 py-0.5 rounded text-sm;
|
||||
}
|
||||
.use-base pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/* Button Size Classes */
|
||||
@layer components {
|
||||
body.dimmed {
|
||||
@apply relative overflow-hidden
|
||||
after:content-[''] after:absolute after:inset-0 after:bg-black/60 after:z-[899];
|
||||
}
|
||||
|
||||
.modal-wrap {
|
||||
@apply overflow-auto fixed inset-0 flex p-5 z-[800];
|
||||
@apply overflow-auto fixed inset-0 flex p-5 z-[900];
|
||||
}
|
||||
|
||||
.modal-wrap.dimmed {
|
||||
@@ -19,4 +23,8 @@
|
||||
.content-text {
|
||||
@apply text-center text-[15px] text-[#333333] leading-6 tracking-[-0.45px];
|
||||
}
|
||||
|
||||
.content-text .highlight {
|
||||
@apply text-[#FC4420];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,10 +58,6 @@ const handlePagination = (page: number) => {
|
||||
currentPage.value = page
|
||||
emits('update:page', page)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log(blocks.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
165
layers/components/blocks/modal/Content.vue
Normal file
165
layers/components/blocks/modal/Content.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -83,9 +83,18 @@ export const useCheckGameStart = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// OS 체크 함수 추가
|
||||
const checkWindowsOS = (): boolean => {
|
||||
// 서버 사이드 렌더링 중에는 navigator 객체가 없으므로, 항상 false를 반환하여 통과시킵니다.
|
||||
// 실제 OS 체크는 클라이언트 사이드에서만 의미가 있습니다.
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return /windows/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// 런처 호출
|
||||
const runLauncher = async () => {
|
||||
// 클라이언트에서만 실행
|
||||
if (!import.meta.client) return
|
||||
|
||||
const gameDataStore = useGameDataStore()
|
||||
@@ -141,6 +150,14 @@ export const useCheckGameStart = () => {
|
||||
const validateLauncher = () => {
|
||||
if (isProcessing.value) return
|
||||
|
||||
const isWindowsOS = checkWindowsOS()
|
||||
if (!isWindowsOS) {
|
||||
modalStore.handleOpenAlert({
|
||||
contentText: tm('Alert_Client_Window'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
debounceHandler()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const isLoading = ref(true)
|
||||
const mainRef = ref<HTMLElement>()
|
||||
|
||||
const { height: viewportH } = useWindowSize()
|
||||
const { bottom: mainBottom } = useElementBounding(mainRef)
|
||||
|
||||
const pinToMain = computed(() => {
|
||||
if (!mainBottom.value) return false
|
||||
return mainBottom.value <= viewportH.value
|
||||
})
|
||||
|
||||
provide('pinToMain', pinToMain)
|
||||
|
||||
onMounted(() => {
|
||||
isLoading.value = false
|
||||
@@ -19,8 +8,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<LayoutsHeader />
|
||||
<AtomsLoadingSimple :is-loading="isLoading" />
|
||||
<main id="LayoutsMain" ref="mainRef" class="relative">
|
||||
<main class="main-default relative">
|
||||
<slot />
|
||||
</main>
|
||||
<LayoutsFooter />
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const mainRef = ref<HTMLElement>()
|
||||
|
||||
const { height: viewportH } = useWindowSize()
|
||||
const { bottom: mainBottom } = useElementBounding(mainRef)
|
||||
|
||||
const pinToMain = computed(() => {
|
||||
if (!mainBottom.value) return false
|
||||
return mainBottom.value <= viewportH.value
|
||||
})
|
||||
|
||||
provide('pinToMain', pinToMain)
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LayoutsHeader />
|
||||
<main id="LayoutsMain" ref="mainRef" class="relative">
|
||||
<main class="main-promotion relative">
|
||||
<BlocksButtonHome />
|
||||
<LayoutsEventNavigation />
|
||||
<slot />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { commonFetch } from '#layers/utils/apiUtil'
|
||||
import { usePageDataStore } from '#layers/stores/usePageDataStore'
|
||||
import { useLoadingStore } from '#layers/stores/useLoadingStore'
|
||||
import { useGetGameDomain } from '#layers/composables/useGetGameDomain'
|
||||
import { usePathResolver } from '#layers/composables/usePathResolver'
|
||||
import type { PageDataResponse } from '#layers/types/api/pageData'
|
||||
import type { GameDataValue } from '#layers/types/api/gameData'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
// client에서만 동작되도록 처리
|
||||
@@ -13,25 +13,22 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
|
||||
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v2.0/template/page`
|
||||
|
||||
const store = usePageDataStore()
|
||||
const gameDomain = useGetGameDomain()
|
||||
const { getPathAfterLanguage } = usePathResolver()
|
||||
const gameDataStore = useGameDataStore()
|
||||
const gameData = gameDataStore.gameData as GameDataValue
|
||||
console.log("🚀 ~ gameDomain:", gameDomain)
|
||||
const { gameData } = storeToRefs(gameDataStore)
|
||||
const pageDataStore = usePageDataStore()
|
||||
const loadingStore = useLoadingStore()
|
||||
const { getPathAfterLanguage } = usePathResolver()
|
||||
|
||||
|
||||
const langCode = csrGetFinalLocale(
|
||||
to.path,
|
||||
gameData?.lang_codes,
|
||||
gameData.value?.lang_codes,
|
||||
)
|
||||
console.log("🚀 ~ to.path:", to.path)
|
||||
console.log("🚀 ~ langCode:", gameData?.lang_codes)
|
||||
console.log("🚀 ~ langCode:", langCode)
|
||||
|
||||
try {
|
||||
|
||||
if (to.path.includes('inspection')) {
|
||||
console.log("🚀 ~ 점검페이지 접근 pageData.global")
|
||||
console.log('🚀 ~ 점검페이지 접근 pageData.global')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +36,12 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
console.log("🚀 ~ pageUrl:", pageUrl)
|
||||
|
||||
// pageUrl이 빈값이거나 null이면 /brand로 리다이렉트
|
||||
if (!pageUrl || pageUrl === '' || pageUrl === '/' || pageUrl === `/${langCode}/`) {
|
||||
if (
|
||||
!pageUrl ||
|
||||
pageUrl === '' ||
|
||||
pageUrl === '/' ||
|
||||
pageUrl === `/${langCode}/`
|
||||
) {
|
||||
return navigateTo(`/${langCode}/brand`, { external: false })
|
||||
}
|
||||
|
||||
@@ -48,6 +50,9 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
if (pageUrl === '/error' || to.path.includes('/error')) {
|
||||
return
|
||||
}
|
||||
// 페이지 이동 시 로딩 상태 시작
|
||||
loadingStore.startFullLoading()
|
||||
|
||||
const accessToken = csrGetAccessToken()
|
||||
|
||||
const headers = {
|
||||
@@ -73,17 +78,16 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const response = (await commonFetch('GET', apiUrl, {
|
||||
headers,
|
||||
query: queryParams,
|
||||
loading: true,
|
||||
})) as PageDataResponse | null
|
||||
console.log('🚀 ~ pageData.global response:', response.value)
|
||||
|
||||
// 페이지 접근 권한 설정(로그인 유무)
|
||||
if(response?.value?.is_login_required === 1 && !accessToken) {
|
||||
if (response?.value?.is_login_required === 1 && !accessToken) {
|
||||
// 로그인 레이어 팝업 띄워주기
|
||||
const nuxtApp = useNuxtApp()
|
||||
const modalStore = useModalStore()
|
||||
const $i18n = nuxtApp.$i18n as any
|
||||
const {tm} = $i18n
|
||||
const { tm } = $i18n
|
||||
modalStore.handleOpenConfirm({
|
||||
contentText: tm('Alert_StoveLogin'),
|
||||
confirmButtonText: tm('Text_StoveLogin'),
|
||||
@@ -93,34 +97,37 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
})
|
||||
}
|
||||
|
||||
if(response?.code === 91003) {
|
||||
if (response?.code === 91003) {
|
||||
// return navigateTo(`/${langCode}/error`, { external: false })
|
||||
//클릭한 주소는 주소표시줄에 표시하도록 수정
|
||||
window.history.replaceState({}, '', to.path)
|
||||
// 뒤로가기 이동 시 이전 페이지로 이동되도록 수정
|
||||
showError(createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '페이지를 찾을 수 없어요.',
|
||||
fatal: false, // 즉시 에러 페이지로
|
||||
data: { reason: 'post-not-found' }
|
||||
}))
|
||||
|
||||
// 뒤로가기 이동 시 이전 페이지로 이동되도록 수정
|
||||
showError(
|
||||
createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '페이지를 찾을 수 없어요.',
|
||||
fatal: false, // 즉시 에러 페이지로
|
||||
data: { reason: 'post-not-found' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (response?.code === 0 && 'value' in response) {
|
||||
store.setPageData(response.value)
|
||||
pageDataStore.setPageData(response.value)
|
||||
} else {
|
||||
store.clearPageData()
|
||||
pageDataStore.clearPageData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
store.clearPageData()
|
||||
pageDataStore.clearPageData()
|
||||
|
||||
showError(createError({
|
||||
statusCode: error.statusCode,
|
||||
statusMessage: error.message,
|
||||
fatal: false, // 즉시 에러 페이지로
|
||||
data: { reason: 'post-not-found' }
|
||||
}))
|
||||
showError(
|
||||
createError({
|
||||
statusCode: error.statusCode,
|
||||
statusMessage: error.message,
|
||||
fatal: false, // 즉시 에러 페이지로
|
||||
data: { reason: 'post-not-found' },
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,8 +11,10 @@ export default defineNuxtPlugin(nuxtApp => {
|
||||
'i',
|
||||
'em',
|
||||
'u',
|
||||
's',
|
||||
'a',
|
||||
'p',
|
||||
'ol',
|
||||
'ul',
|
||||
'li',
|
||||
'span',
|
||||
@@ -23,6 +25,11 @@ export default defineNuxtPlugin(nuxtApp => {
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
'blockquote',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
@@ -39,6 +46,9 @@ export default defineNuxtPlugin(nuxtApp => {
|
||||
'height',
|
||||
'frameborder',
|
||||
'allowfullscreen',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'scope',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,29 +2,43 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const useLoadingStore = defineStore('loadingStore', () => {
|
||||
// 글로벌 로딩 표기
|
||||
const fullLoading = ref(false)
|
||||
const fullLoading = ref(true)
|
||||
const hasApiCallStarted = ref(false)
|
||||
const isPAssApiLoading = ref(false)
|
||||
// 컴포넌트별 로딩 표기 - Map 대신 일반 객체 사용
|
||||
const localLoadings = ref<Record<string, { active: boolean }>>({})
|
||||
// 로딩 상태만 표기
|
||||
const isLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 모든 로딩 상태 초기화
|
||||
* 로딩 상태 초기화
|
||||
*/
|
||||
const initializeStore = () => {
|
||||
fullLoading.value = false
|
||||
localLoadings.value = {}
|
||||
hasApiCallStarted.value = false
|
||||
isPAssApiLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Full 로딩
|
||||
*/
|
||||
const startFullLoading = () => {
|
||||
initializeStore()
|
||||
fullLoading.value = true
|
||||
}
|
||||
const startApiLoading = () => {
|
||||
hasApiCallStarted.value = true
|
||||
isPAssApiLoading.value = false
|
||||
}
|
||||
|
||||
const stopFullLoading = () => {
|
||||
fullLoading.value = false
|
||||
if (!hasApiCallStarted.value || isPAssApiLoading.value) {
|
||||
fullLoading.value = false
|
||||
}
|
||||
}
|
||||
const finishApiLoading = () => {
|
||||
setTimeout(() => {
|
||||
hasApiCallStarted.value = false
|
||||
isPAssApiLoading.value = true
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,24 +58,19 @@ export const useLoadingStore = defineStore('loadingStore', () => {
|
||||
return !!localLoadings.value[localId]?.active
|
||||
}
|
||||
|
||||
/**
|
||||
* 로딩 상태 변경
|
||||
*/
|
||||
const setLoading = (state: boolean) => {
|
||||
isLoading.value = state
|
||||
}
|
||||
|
||||
return {
|
||||
fullLoading,
|
||||
localLoadings,
|
||||
isLoading,
|
||||
hasApiCallStarted,
|
||||
isPAssApiLoading,
|
||||
|
||||
startApiLoading,
|
||||
finishApiLoading,
|
||||
initializeStore,
|
||||
startFullLoading,
|
||||
stopFullLoading,
|
||||
startLocalLoading,
|
||||
stopLocalLoading,
|
||||
isLocalLoading,
|
||||
setLoading,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineStore, skipHydrate } from 'pinia'
|
||||
import type {
|
||||
DialogParams,
|
||||
YoutubeParams,
|
||||
ToastParams,
|
||||
ContentParams,
|
||||
} from '#layers/types/components/modal'
|
||||
|
||||
const createModalState = () => ({
|
||||
@@ -17,6 +19,19 @@ const createModalState = () => ({
|
||||
export const useModalStore = defineStore('modalStore', () => {
|
||||
const scrollStore = useScrollStore()
|
||||
|
||||
// dimmed ------------------
|
||||
/**
|
||||
* @description 모달을 바디에서 컨트롤 필요 시 사용하는 함수입니다.
|
||||
* @param state - 모달 바디 상태
|
||||
*/
|
||||
const handleControlDimmed = (state: boolean) => {
|
||||
if (state) {
|
||||
document.body.classList.add('dimmed')
|
||||
} else {
|
||||
document.body.classList.remove('dimmed')
|
||||
}
|
||||
}
|
||||
|
||||
// alert ------------------
|
||||
const alert = {
|
||||
...createModalState(),
|
||||
@@ -101,7 +116,7 @@ export const useModalStore = defineStore('modalStore', () => {
|
||||
storeContentText: ref(''),
|
||||
}
|
||||
|
||||
const handleOpenToast = (contentText: string, duration: number = 2000) => {
|
||||
const handleOpenToast = ({ contentText, duration = 2000 }: ToastParams) => {
|
||||
toast.storeIsOpen.value = true
|
||||
toast.storeContentText.value = contentText
|
||||
|
||||
@@ -111,15 +126,43 @@ export const useModalStore = defineStore('modalStore', () => {
|
||||
}, duration)
|
||||
}
|
||||
|
||||
// content ------------------
|
||||
const content = {
|
||||
storeIsOpen: ref(false),
|
||||
storeModalName: ref(''),
|
||||
storeIsOutsideClose: ref(false),
|
||||
storeContentTitle: ref(''),
|
||||
storeTabInfo: ref(null),
|
||||
storeTabActiveIndex: ref(0),
|
||||
}
|
||||
|
||||
const handleOpenContent = ({
|
||||
isOutsideClose = false,
|
||||
modalName = '',
|
||||
contentTitle,
|
||||
tabInfo,
|
||||
tabActiveIndex,
|
||||
}: ContentParams) => {
|
||||
content.storeIsOpen.value = true
|
||||
content.storeModalName.value = modalName
|
||||
content.storeIsOutsideClose.value = isOutsideClose
|
||||
content.storeContentTitle.value = contentTitle
|
||||
content.storeTabInfo.value = tabInfo
|
||||
content.storeTabActiveIndex.value = tabActiveIndex
|
||||
}
|
||||
|
||||
return {
|
||||
alert,
|
||||
confirm,
|
||||
youtube,
|
||||
toast,
|
||||
content,
|
||||
handleOpenAlert,
|
||||
handleOpenConfirm,
|
||||
handleOpenYoutube,
|
||||
handleResetYoutube,
|
||||
handleOpenToast,
|
||||
handleOpenContent,
|
||||
handleControlDimmed,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ReqCouponList } from '#layers/types/api/couponData'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
id?: string
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
}
|
||||
@@ -106,7 +107,6 @@ const sortedCharacterList = computed(() => {
|
||||
*/
|
||||
const openAlert = (text: string) => {
|
||||
handleOpenAlert({
|
||||
isShowDimmed: true,
|
||||
contentText: text,
|
||||
isOutsideClose: true,
|
||||
})
|
||||
@@ -486,8 +486,9 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<WidgetsFixMainTitle
|
||||
:id="props.id"
|
||||
:title="tm('Coupon_Page_Title')"
|
||||
:resourcesData="backgroundData"
|
||||
:resources-data="backgroundData"
|
||||
/>
|
||||
|
||||
<div class="section-container static">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Platform } from '#layers/types/components/button'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
id?: string
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
}
|
||||
@@ -131,6 +132,7 @@ const handleMoveFocus = (target: 'pc' | 'mobile') => {
|
||||
|
||||
<template>
|
||||
<WidgetsFixMainTitle
|
||||
:id="props.id"
|
||||
:title="tm('Download_Page_Title')"
|
||||
:resources-data="backgroundData"
|
||||
/>
|
||||
|
||||
@@ -33,11 +33,6 @@ const { pageData } = storeToRefs(usePageDataStore())
|
||||
|
||||
// Constants
|
||||
const COLOR_INDEX = { BACKGROUND: 0, TEXT: 1 } as const
|
||||
const OS_TYPE_MAP: Record<string, Platform[]> = {
|
||||
'1': ['google_play'],
|
||||
'2': ['app_store'],
|
||||
'3': ['google_play', 'app_store'],
|
||||
}
|
||||
|
||||
const preregistModalRef = ref<{
|
||||
handleOpenPreregist: () => Promise<void>
|
||||
@@ -101,12 +96,6 @@ const buttonColors = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Platform Buttons
|
||||
const platformButtons = computed<Platform[]>(() => {
|
||||
const osType = String(gameData.value?.os_type ?? '')
|
||||
return OS_TYPE_MAP[osType] ?? []
|
||||
})
|
||||
|
||||
// Reward Section
|
||||
const accBackgroundData = computed(() =>
|
||||
getComponentGroup(props.components, 'backgroundAccReward')
|
||||
@@ -280,10 +269,10 @@ const handlePreregistClick = () => {
|
||||
{{ tm('Preregist_Btn_Preegist') }}
|
||||
</BlocksButtonLauncher>
|
||||
<BlocksButtonLauncher
|
||||
v-for="platform in platformButtons"
|
||||
v-for="platform in getSupportedPlatforms(gameData?.os_type)"
|
||||
:key="`preregist-${platform}`"
|
||||
type="duplication"
|
||||
:platform="platform"
|
||||
:platform="platform as Platform"
|
||||
:background-color="buttonColors.backgroundColor"
|
||||
:text-color="buttonColors.textColor"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getImageHost } from '#layers/utils/styleUtil'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
id?: string
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
}
|
||||
@@ -47,7 +48,6 @@ const checkLoginValidation = async () => {
|
||||
const validateTokenResult = await handleTokenValidation(
|
||||
accessToken.value || ''
|
||||
)
|
||||
console.log("🚀 ~ checkLoginValidation ~ validateTokenResult:", validateTokenResult)
|
||||
isLogin.value = validateTokenResult
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ const fnGetSecuritySetting = async () => {
|
||||
try {
|
||||
const result = await commonFetch('GET', `${apiBase}/security/setting`, {
|
||||
headers,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
if (result?.code === 0 && Array.isArray(result.value)) {
|
||||
@@ -142,6 +143,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<WidgetsFixMainTitle
|
||||
:id="props.id"
|
||||
:title="tm('Secure_Page_Title') || '보안 강화 캠페인'"
|
||||
:resources-data="backgroundData"
|
||||
class="mx-auto"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
import type { OperateGroupItem } from '#layers/types/api/operateResources'
|
||||
|
||||
interface Props {
|
||||
id?: string
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
}
|
||||
@@ -119,10 +120,10 @@ const handleLoadMoreRecent = () => {
|
||||
|
||||
<template>
|
||||
<WidgetsFixMainTitle
|
||||
:id="props.id"
|
||||
:title="tm('Video_Page_Title')"
|
||||
:resources-data="backgroundData"
|
||||
/>
|
||||
|
||||
<div class="section-container static">
|
||||
<section class="section-static">
|
||||
<WidgetsFixSubTitle
|
||||
|
||||
@@ -52,12 +52,16 @@ export interface GameDataValue {
|
||||
comm_img_json: GameDataCommImg
|
||||
market_json: Record<string, { url: string }>
|
||||
event_banner: GameDataEventBanner
|
||||
os_type: string // 1:AOS, 2:IOS, 3:둘다
|
||||
platform_type: string // 1:PC, 2:MOBILE, 3:둘다
|
||||
os_type: OsType
|
||||
platform_type: PlatformType
|
||||
}
|
||||
|
||||
// ===== 세부 데이터 타입들 =====
|
||||
|
||||
export type OsType = '1' | '2' | '3' // 1:AOS, 2:IOS, 3:둘다
|
||||
export type PlatformType = '1' | '2' | '3' // 1:PC, 2:MOBILE, 3:둘다
|
||||
export type PlatformTransformType = 'pc' | 'google_play' | 'app_store'
|
||||
|
||||
// 키 코드 코드 타입
|
||||
export interface GameDataKeyColors {
|
||||
primary: string
|
||||
|
||||
@@ -15,3 +15,19 @@ export interface YoutubeParams {
|
||||
isOutsideClose?: boolean
|
||||
modalName?: string
|
||||
}
|
||||
|
||||
export interface ToastParams {
|
||||
contentText: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface ContentParams {
|
||||
isOutsideClose?: boolean
|
||||
modalName?: string
|
||||
contentTitle: string
|
||||
tabInfo?: {
|
||||
title: string
|
||||
desc: string
|
||||
}[]
|
||||
tabActiveIndex?: number
|
||||
}
|
||||
|
||||
@@ -20,12 +20,8 @@ const startLoading = (
|
||||
) => {
|
||||
if (!loadingStore) return
|
||||
|
||||
loadingStore.setLoading(true)
|
||||
|
||||
if (typeof loading === 'object' && loading.localId) {
|
||||
loadingStore.startLocalLoading(loading.localId)
|
||||
} else if (typeof loading === 'boolean') {
|
||||
loadingStore.startFullLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +34,12 @@ const stopLoading = (
|
||||
) => {
|
||||
if (!loadingStore) return
|
||||
|
||||
loadingStore.setLoading(false)
|
||||
|
||||
if (typeof loading === 'object' && loading.localId) {
|
||||
loadingStore.stopLocalLoading(loading.localId)
|
||||
} else if (typeof loading === 'boolean') {
|
||||
loadingStore.stopFullLoading()
|
||||
return
|
||||
}
|
||||
if (loading === true) {
|
||||
loadingStore.finishApiLoading()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @description gameData, pageData 처리에 필요한 유틸리티 함수를 제공합니다.
|
||||
*/
|
||||
|
||||
import type { PlatformType } from '#layers/types/api/gameData'
|
||||
import type {
|
||||
PageDataValue,
|
||||
PageDataResourceContainer,
|
||||
@@ -12,6 +13,37 @@ import type {
|
||||
} from '#layers/types/api/pageData'
|
||||
import type { OperateComponents } from '#layers/types/api/operateResources'
|
||||
|
||||
const OS_TYPE_MAP: Record<string, string[]> = {
|
||||
'1': ['google_play'],
|
||||
'2': ['app_store'],
|
||||
'3': ['google_play', 'app_store'],
|
||||
}
|
||||
|
||||
/**
|
||||
* OS 타입에 따라 가능한 플랫폼 목록을 반환합니다.
|
||||
*/
|
||||
export const getSupportedPlatforms = (
|
||||
osType: string | number,
|
||||
options?: { platformType?: PlatformType }
|
||||
): string[] => {
|
||||
const type = String(osType)
|
||||
const platformType = String(options?.platformType ?? '0')
|
||||
const storePlatforms = OS_TYPE_MAP[type] ?? []
|
||||
|
||||
switch (platformType) {
|
||||
case '1': // PC 전용
|
||||
return ['pc']
|
||||
|
||||
case '2': // 모바일 스토어 전용
|
||||
return storePlatforms
|
||||
|
||||
case '3': // PC + 모바일 스토어 모두
|
||||
return ['pc', ...storePlatforms]
|
||||
|
||||
default: // 기본: OS_TYPE_MAP
|
||||
return storePlatforms
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 페이지 데이터를 기반으로 레이아웃 타입을 결정합니다.
|
||||
* @param pageData 페이지 데이터
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { isTypeVideo } from '#layers/utils/dataUtil'
|
||||
import type { GameDataResourceGroupBtnInfo } from '#layers/types/api/gameData'
|
||||
import type {
|
||||
PageDataResourceGroups,
|
||||
PageDataResourceGroup,
|
||||
PageDataResourceGroupResPath,
|
||||
PageDataResourceGroupBtnInfo,
|
||||
} from '#layers/types/api/pageData'
|
||||
|
||||
/**
|
||||
@@ -92,11 +94,29 @@ export const getColorCode = ({
|
||||
colorName: string
|
||||
colorCode: string
|
||||
}) => {
|
||||
if (colorName) {
|
||||
return `var(--${colorName})`
|
||||
} else if (colorCode) {
|
||||
return colorCode
|
||||
}
|
||||
if (colorName) return `var(--${colorName})`
|
||||
else if (colorCode) return colorCode
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상데이터를 받아 색상값을 반환합니다.
|
||||
* @param colorData 색상 데이터
|
||||
* @param type 색상 타입 (btn: 버튼, txt: 텍스트)
|
||||
* @returns 색상 값
|
||||
*/
|
||||
export const getColorCodeFromData = (
|
||||
data: GameDataResourceGroupBtnInfo | PageDataResourceGroupBtnInfo,
|
||||
type: 'btn' | 'txt' = 'txt'
|
||||
) => {
|
||||
const suffix = type === 'btn' ? '_btn' : '_txt'
|
||||
|
||||
const colorName = data?.[`color_name${suffix}` as keyof typeof data]
|
||||
const colorCode = data?.[`color_code${suffix}` as keyof typeof data]
|
||||
|
||||
return getColorCode({
|
||||
colorName: colorName as string | undefined,
|
||||
colorCode: colorCode as string | undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user