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

This commit is contained in:
“hyeonggkim”
2025-11-19 19:45:30 +09:00
31 changed files with 476 additions and 333 deletions

View File

@@ -3,39 +3,47 @@
<LayoutsStoveHeader /> <LayoutsStoveHeader />
<Transition name="fade"> <Transition name="fade">
<div v-if="!isLoading" class="flex-1 flex items-center justify-center p-25"> <div
v-if="!isLoading"
class="flex-1 flex items-center justify-center p-25"
>
<div class="flex flex-col items-center gap-6 w-full"> <div class="flex flex-col items-center gap-6 w-full">
<!-- Stove Logo --> <!-- Stove Logo -->
<div class="flex items-center justify-center h-7"> <div class="flex items-center justify-center h-7">
<img <img
src="/images/common/logo-stove.svg" src="/images/common/logo-stove.svg"
alt="Stove" alt="Stove"
class="h-full w-auto" class="h-full w-auto"
/>
</div>
<!-- Error Icon and Text -->
<div class="flex flex-col items-center gap-4 w-full">
<!-- Error Icon -->
<div class="flex items-center justify-center">
<img
src="/images/common/img_error.png"
alt="Error"
class="w-40 h-40 md:w-60 md:h-60"
/> />
</div> </div>
<!-- Error Text --> <!-- Error Icon and Text -->
<div class="flex flex-col items-center gap-2 w-full"> <div class="flex flex-col items-center gap-4 w-full">
<h1 class="font-medium text-xl md:text-2xl leading-[1.5] tracking-[-0.03em] text-center text-[#1F1F1F] m-0"> <!-- Error Icon -->
<span v-dompurify-html="errorTitle"></span> <div class="flex items-center justify-center">
</h1> <img
<p v-dompurify-html="errorMsg" class="font-normal text-sm md:text-base leading-[1.7] md:leading-[1.625] tracking-[-0.03em] text-center text-[#666666] m-0"></p> src="/images/common/img_error.png"
</div> alt="Error"
</div> class="w-40 h-40 md:w-60 md:h-60"
/>
</div>
<!-- Home Button --> <!-- Error Text -->
<AtomsButton <div class="flex flex-col items-center gap-2 w-full">
<h1
class="font-medium text-xl md:text-2xl leading-[1.5] tracking-[-0.03em] text-center text-[#1F1F1F] m-0"
>
<span v-dompurify-html="errorTitle"></span>
</h1>
<p
v-dompurify-html="errorMsg"
class="font-normal text-sm md:text-base leading-[1.7] md:leading-[1.625] tracking-[-0.03em] text-center text-[#666666] m-0"
></p>
</div>
</div>
<!-- Home Button -->
<AtomsButton
type="action" type="action"
button-size="size-small md:size-large" button-size="size-small md:size-large"
background-color="#FC4420" background-color="#FC4420"
@@ -50,7 +58,6 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface ErrorProps { interface ErrorProps {
error?: { error?: {
@@ -62,7 +69,7 @@ interface ErrorProps {
const { tm } = useI18n() const { tm } = useI18n()
const props = withDefaults(defineProps<ErrorProps>(), { const props = withDefaults(defineProps<ErrorProps>(), {
error: () => ({}) error: () => ({}),
}) })
const nuxtError = useError() const nuxtError = useError()
const currentError = computed(() => props.error || nuxtError.value) const currentError = computed(() => props.error || nuxtError.value)
@@ -72,13 +79,12 @@ const isLoading = ref(true)
const errorTitle = ref('') const errorTitle = ref('')
const errorMsg = ref('') const errorMsg = ref('')
//error clear 함수 생성 //error clear 함수 생성
const localePath = useLocalePath() const localePath = useLocalePath()
// const handleError = () => clearError({ redirect: '/' }) // const handleError = () => clearError({ redirect: '/' })
const handleError = () => { const handleError = () => {
window.location.href = localePath('/') window.location.href = localePath('/')
// clearError({ redirect: `${localePath('/brand')}` })
} }
const handleBack = () => { const handleBack = () => {
// 에러 상태를 클리어하고 이전 페이지로 이동 // 에러 상태를 클리어하고 이전 페이지로 이동
@@ -90,15 +96,16 @@ const handleBack = () => {
// 백스페이스 키 처리 // 백스페이스 키 처리
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' && if (
!['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) { e.key === 'Backspace' &&
!['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)
) {
e.preventDefault() e.preventDefault()
handleBack() handleBack()
} }
} }
// 500 에러 발생 시 /error 페이지로 리다이렉트 // 500 에러 발생 시 /error 페이지로 리다이렉트
onMounted(() => { onMounted(() => {
const statusCode = currentError.value?.statusCode const statusCode = currentError.value?.statusCode
if (statusCode === 500) { if (statusCode === 500) {
@@ -108,19 +115,16 @@ onMounted(() => {
errorTitle.value = tm('Error_404_Not_Found') errorTitle.value = tm('Error_404_Not_Found')
errorMsg.value = tm('Error_404_Not_Found2') errorMsg.value = tm('Error_404_Not_Found2')
} }
nextTick(() => { nextTick(() => {
isLoading.value = false isLoading.value = false
}) })
window.addEventListener('keydown', handleKeydown) window.addEventListener('keydown', handleKeydown)
window.addEventListener('popstate', handleBack) window.addEventListener('popstate', handleBack)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown) window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('popstate', handleBack) window.removeEventListener('popstate', handleBack)
}) })
</script>
</script>

View File

@@ -1,7 +1,9 @@
@import './base/_theme.css';
@import './base/_reset.css'; @import './base/_reset.css';
@import './base/_theme.css';
@import './base/_font.css';
@import './base/_transition.css'; @import './base/_transition.css';
@import './components/_scrollbar.css';
@import './components/_button.css'; @import './components/_button.css';
@import './components/_layout.css'; @import './components/_layout.css';
@import './components/_modal.css'; @import './components/_modal.css';

View File

@@ -0,0 +1,92 @@
@import url(https://static-cdn.onstove.com/0.0.1/font/SpoqaSans/StoveUIFont-KR.css);
@import url(https://static-cdn.onstove.com/0.0.4/font/Inter/StoveFont-Global.css);
@import url(https://static-cdn.onstove.com/0.0.4/font-icon/StoveFont-Icon.css);
/* @import url(https://static-cdn.onstove.com/0.0.1/font/SpoqaSans/StoveFont-KR.css); */
:lang(ko) {
font-family:
Spoqa Han Sans Neo KR,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(en) {
font-family:
inter Global,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(zh),
:lang(zh-cn) {
font-family:
Microsoft YaHei UI,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(zh-tw) {
font-family:
Microsoft JhengHei UI,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(ja) {
font-family:
Meiryo Yu Gothic UI,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(de),
:lang(es),
:lang(fr),
:lang(pt) {
font-family:
inter Global,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}
:lang(th) {
font-family:
Leelawadee UI,
-apple-system,
Segoe UI,
Helvetica,
Arial,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji;
}

View File

@@ -2,6 +2,7 @@
@layer base { @layer base {
body { body {
@apply min-w-[320px] bg-black; @apply min-w-[320px] bg-black;
-webkit-font-smoothing: antialiased;
} }
body.scroll-lock { body.scroll-lock {
@apply overflow-hidden; @apply overflow-hidden;

View File

@@ -28,9 +28,6 @@
.use-base th { .use-base th {
@apply font-semibold bg-gray-50; @apply font-semibold bg-gray-50;
} }
.use-base tbody tr:nth-child(even) {
@apply bg-gray-50;
}
.use-base blockquote { .use-base blockquote {
@apply border-l-4 border-gray-300 pl-4 italic text-gray-700; @apply border-l-4 border-gray-300 pl-4 italic text-gray-700;

View File

@@ -27,13 +27,13 @@
/* 표준형 Title Classes */ /* 표준형 Title Classes */
.title-xlg { .title-xlg {
@apply line-clamp-4 text-[24px] font-[700] leading-[34px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-3 md:text-[50px] md:leading-[70px]; @apply line-clamp-4 text-[24px] font-[700] tracking-[-0.82px] leading-[34px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:tracking-[-1.5px] md:line-clamp-3 md:text-[50px] md:leading-[70px];
} }
.title-lg { .title-lg {
@apply line-clamp-4 text-[20px] font-[700] leading-[30px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-3 md:text-[42px] md:leading-[56px]; @apply line-clamp-4 text-[20px] font-[700] tracking-[-0.6px] leading-[30px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:tracking-[-1.26px] md:line-clamp-3 md:text-[42px] md:leading-[56px];
} }
.title-md { .title-md {
@apply line-clamp-2 text-[16px] font-[500] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-1 md:text-[24px] md:leading-[34px]; @apply line-clamp-2 text-[16px] font-[500] tracking-[-0.48px] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:tracking-[-0.72px] md:line-clamp-1 md:text-[24px] md:leading-[34px];
} }
.title-sm { .title-sm {
@apply text-[15px] font-[500] leading-[24px] tracking-[-0.45px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:text-[20px] md:leading-[30px] md:tracking-[-0.6px]; @apply text-[15px] font-[500] leading-[24px] tracking-[-0.45px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:text-[20px] md:leading-[30px] md:tracking-[-0.6px];

View File

@@ -0,0 +1,14 @@
body ::-webkit-scrollbar-track {
@apply bg-transparent;
}
body ::-webkit-scrollbar {
@apply w-5;
}
body ::-webkit-scrollbar-thumb {
@apply w-1 bg-[#D9D9D9] rounded-full bg-clip-padding border-solid border-transparent border-8;
}
.custom-theme-scrollbar::-webkit-scrollbar-thumb {
@apply bg-[var(--foreground-reversal-15)];
}

View File

@@ -1,26 +1,14 @@
<script setup lang="ts"> <script setup lang="ts"></script>
const scrollStore = useScrollStore()
const { isPassedStoveGnb } = storeToRefs(scrollStore)
</script>
<template> <template>
<AtomsButtonCircle <AtomsButtonCircle sr-only="home" type="link" to="/" class="btn-home">
sr-only="home"
type="link"
to="/"
:class="['btn-home', { 'is-fixed': isPassedStoveGnb }]"
>
<AtomsIconsHomeFill /> <AtomsIconsHomeFill />
</AtomsButtonCircle> </AtomsButtonCircle>
</template> </template>
<style scoped> <style scoped>
.btn-home { .btn-home {
@apply absolute top-3 right-3 mt-[48px] bg-black/20 shadow-[0_1.667px_3.333px_0_rgba(0,0,0,0.06)] backdrop-blur-[12.5px] z-[100] @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-[64px]; sm:top-5 md:top-6 md:right-8 md:mt-[calc(var(--scroll-position,64px)+64px)];
}
.btn-home.is-fixed {
@apply fixed;
} }
</style> </style>

View File

@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PageDataLnbMenu } from '#layers/types/api/pageData' import type { PageDataLnbMenu } from '#layers/types/api/pageData'
const { y: windowY } = useWindowScroll({ behavior: 'smooth' }) const { y: windowY, directions } = useWindowScroll({ behavior: 'smooth' })
const pageDataStore = usePageDataStore() const pageDataStore = usePageDataStore()
const breakpoints = useResponsiveBreakpoints() const breakpoints = useResponsiveBreakpoints()
const { pageData } = storeToRefs(pageDataStore) const { pageData } = storeToRefs(pageDataStore)
const observerOptions = { // 상수 정의
const HEADER_HEIGHT = 64
const OBSERVER_OPTIONS = {
root: null, root: null,
rootMargin: '-20% 0px -60% 0px', // 상단 20%, 하단 60% 마진 rootMargin: '-20% 0px -60% 0px', // 상단 20%, 하단 60% 마진
threshold: 0, threshold: 0,
} } as const
const isShowLnbWithScroll = ref(false)
const activeSection = ref<string>('') const activeSection = ref<string>('')
const lnbList = computed<Record<string, PageDataLnbMenu>>( const lnbList = computed<Record<string, PageDataLnbMenu>>(
@@ -39,8 +43,9 @@ const is1DepthActive = (lnbItem: PageDataLnbMenu): boolean => {
} }
// children 중 하나가 활성화된 경우 // children 중 하나가 활성화된 경우
if (lnbItem.children && Object.keys(lnbItem.children).length > 0) { const children = lnbItem.children
return Object.values(lnbItem.children).some( if (children && Object.keys(children).length > 0) {
return Object.values(children).some(
child => activeSection.value === child.page_ver_tmpl_name_en child => activeSection.value === child.page_ver_tmpl_name_en
) )
} }
@@ -48,54 +53,73 @@ const is1DepthActive = (lnbItem: PageDataLnbMenu): boolean => {
return false return false
} }
const observer = new IntersectionObserver(entries => { // IntersectionObserver 콜백: 교차하는 섹션들 중 가장 위에 있는 것을 활성화
const handleIntersection = (entries: IntersectionObserverEntry[]) => {
if (import.meta.server) return if (import.meta.server) return
// 교차하는 섹션들 중 가장 위에 있는 것을 활성화
const visibleEntries = entries const visibleEntries = entries
.filter(entry => entry.isIntersecting) .filter(entry => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visibleEntries.length > 0) { if (visibleEntries.length > 0) {
activeSection.value = visibleEntries[0].target.id const topEntry = visibleEntries[0]
activeSection.value = topEntry.target.id
} }
}, observerOptions) }
const observer = new IntersectionObserver(handleIntersection, OBSERVER_OPTIONS)
// 요소 관찰 헬퍼 함수
const observeElement = (elementId: string) => {
const element = document.getElementById(elementId)
if (element) {
observer.observe(element)
}
}
// 섹션들을 관찰 시작
const observeSections = () => { const observeSections = () => {
if (import.meta.server) return if (import.meta.server) return
Object.values(lnbList.value).forEach(lnbItem => { Object.values(lnbList.value).forEach(lnbItem => {
// 1depth 관찰 // 1depth 관찰
const el = document.getElementById(lnbItem.page_ver_tmpl_name_en) observeElement(lnbItem.page_ver_tmpl_name_en)
if (el) {
observer.observe(el)
}
// 2depth 관찰 // 2depth 관찰
if (lnbItem.children && Object.keys(lnbItem.children).length > 0) { const children = lnbItem.children
Object.values(lnbItem.children).forEach(childItem => { if (children && Object.keys(children).length > 0) {
const childEl = document.getElementById(childItem.page_ver_tmpl_name_en) Object.values(children).forEach(childItem => {
if (childEl) { observeElement(childItem.page_ver_tmpl_name_en)
observer.observe(childEl)
}
}) })
} }
}) })
} }
// LNB 클릭 핸들러: 해당 섹션으로 스크롤
const handleLnbClick = (lnbItem: PageDataLnbMenu) => { const handleLnbClick = (lnbItem: PageDataLnbMenu) => {
if (import.meta.server) return if (import.meta.server) return
const id = lnbItem.page_ver_tmpl_name_en const targetId =
const el = document.getElementById(id) lnbItem.page_ver_tmpl_name_en === ''
if (!el) return ? lnbItem?.children?.['1']?.page_ver_tmpl_name_en
: lnbItem.page_ver_tmpl_name_en
const elementTop = el.getBoundingClientRect().top const targetElement = document.getElementById(targetId)
if (!targetElement) return
// 헤더 높이를 고려한 스크롤 위치 계산
const elementTop = targetElement.getBoundingClientRect().top
const currentScrollY = window.scrollY const currentScrollY = window.scrollY
const headerHeight = 64 const targetScrollY = currentScrollY + elementTop - HEADER_HEIGHT
const targetScrollY = currentScrollY + elementTop - headerHeight
windowY.value = targetScrollY windowY.value = targetScrollY
} }
watch(directions, newVal => {
// 스크롤 업일 때만 표시, 다운이거나 멈춘 상태에서는 숨김
isShowLnbWithScroll.value = newVal.top === true
})
onMounted(() => { onMounted(() => {
observeSections() observeSections()
}) })
@@ -106,79 +130,109 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div <template v-if="isShowLnb">
v-if="isShowLnb" <div
class="lnb-wrap" :class="['lnb-wrap', { 'is-hidden': !isShowLnbWithScroll }]"
:style="{ :style="{
'--lnb-active-color': activeColor, '--lnb-active-color': activeColor,
'--lnb-disable-color': disableColor, '--lnb-disable-color': disableColor,
}" }"
> >
<ul class="main-list"> <ul class="lnb-main">
<li v-for="lnbItem in lnbList" :key="lnbItem.path_code"> <li v-for="lnbItem in lnbList" :key="lnbItem.path_code">
<button <button
v-dompurify-html="lnbItem.menu_name" v-dompurify-html="lnbItem.menu_name"
type="button" type="button"
:class="['btn-1depth', { 'is-active': is1DepthActive(lnbItem) }]" :class="['btn-1depth', { 'is-active': is1DepthActive(lnbItem) }]"
@click="handleLnbClick(lnbItem)" @click="handleLnbClick(lnbItem)"
></button> ></button>
<ul <ul
v-if="Object.keys(lnbItem.children || {}).length > 0" v-if="Object.keys(lnbItem.children || {}).length > 0"
class="sub-list" class="lnb-sub"
> >
<li v-for="subItem in lnbItem.children" :key="subItem.path_code"> <li v-for="subItem in lnbItem.children" :key="subItem.path_code">
<button <button
v-dompurify-html="subItem.menu_name" v-dompurify-html="subItem.menu_name"
type="button" type="button"
:class="[ :class="[
'btn-2depth', 'btn-2depth',
{ {
'is-active': activeSection === subItem.page_ver_tmpl_name_en, 'is-active':
}, activeSection === subItem.page_ver_tmpl_name_en,
]" },
@click="handleLnbClick(subItem)" ]"
></button> @click="handleLnbClick(subItem)"
</li> ></button>
</ul> </li>
</li> </ul>
</ul> </li>
</div> </ul>
</div>
</template>
</template> </template>
<style scoped> <style scoped>
.lnb-wrap { .lnb-wrap {
@apply fixed top-1/2 right-0 py-12 pr-[42px] text-right -translate-y-1/2 bg-[radial-gradient(100%_50%_at_100%_50%,rgba(0,0,0,0.5)_25%,rgba(0,0,0,0)_100%)] z-50; @apply fixed top-0 right-0 mt-[calc(var(--scroll-position,48px)+64px)] py-8 pr-10 bg-[radial-gradient(100%_50%_at_100%_50%,rgba(0,0,0,0.4)_25%,rgba(0,0,0,0)_100%)] transition-transform duration-[400ms] ease-in-out z-50;
} }
.main-list { .lnb-wrap:before {
content: '';
position: absolute;
top: 0;
left: -5%;
width: 105%;
height: 100%;
backdrop-filter: blur(5px);
mask-image: radial-gradient(
circle at right center,
#000 0%,
#000 40%,
transparent 80%
);
mask-size: 100% 100%;
mask-repeat: no-repeat;
z-index: -1;
}
.lnb-wrap.is-hidden {
@apply translate-x-[110%] delay-[800ms];
}
.lnb-main {
@apply flex flex-col gap-4 items-end; @apply flex flex-col gap-4 items-end;
} }
.lnb-main > li {
@apply flex flex-col items-end;
}
.btn-1depth { .btn-1depth {
@apply text-[15px] leading-[26px] tracking-[-0.54px]; @apply text-[15px] leading-[26px] tracking-[-0.54px];
} }
.sub-list { .lnb-sub {
@apply flex flex-col gap-2 items-end mt-4 mb-1 pr-[46px]; @apply flex flex-col gap-2 items-end mt-4 mb-1 pr-[16px];
} }
.btn-2depth { .btn-2depth {
@apply text-[14px] leading-[20px] tracking-[-0.42px]; @apply text-[14px] leading-[20px] tracking-[-0.42px];
} }
button { button {
@apply relative font-[500] text-[var(--lnb-disable-color)] transition-all duration-300 ease-in-out; @apply flex items-center font-[500] text-[var(--lnb-disable-color)] transition-all duration-300 ease-in-out;
} }
button:hover, button:hover,
button.is-active { button.is-active {
@apply text-[var(--lnb-active-color)]; @apply text-[var(--lnb-active-color)];
} }
button::before { button::after {
@apply content-[''] absolute top-1/2 rounded-full -translate-y-1/2 bg-transparent transition-all duration-300 ease-in-out; @apply content-[''] rounded-full ml-2 bg-[var(--lnb-disable-color)] transition-all duration-300 ease-in-out;
} }
button.is-active::before { button.is-active::after {
@apply bg-[var(--lnb-active-color)]; @apply bg-[var(--lnb-active-color)];
} }
.btn-1depth::before { .btn-1depth::after {
@apply -right-4 w-1.5 h-1.5; @apply -right-4 w-1.5 h-1.5;
} }
.btn-2depth::before { .btn-2depth::after {
@apply -right-3.5 w-1 h-1; @apply -right-3.5 w-1 h-1;
} }
.main-promotion .lnb-wrap {
@apply mt-[calc(var(--scroll-position,48px)+64px+72px)];
}
</style> </style>

View File

@@ -28,7 +28,7 @@ const loadGnb = (locale: string) => {
const gnbOption = { const gnbOption = {
wrapper: '#stove-wrap', wrapper: '#stove-wrap',
isResponsive: true, isResponsive: true,
skin: stoveGnbData?.skin_type || 'gnb-dark-mini', skin: 'gnb-dark-mini',
widget: { widget: {
gameListAndService: false, gameListAndService: false,
languageSelect: false, languageSelect: false,

View File

@@ -22,8 +22,8 @@ onMounted(() => {
mobile: '', mobile: '',
}, },
widget: { widget: {
notification: true, notification: stoveGnbData?.notify_icon_visible || true,
stoveDownload: true, stoveDownload: stoveGnbData?.stove_install_button_visible || true,
languageSelect: false, languageSelect: false,
themeSelect: false, themeSelect: false,
stoveMenu: { stoveMenu: {
@@ -40,9 +40,8 @@ onMounted(() => {
}, },
mode: { mode: {
theme: { theme: {
support: ['light', 'dark'], support: designTheme === 1 ? ['light'] : ['dark'],
default: designTheme === 1 ? 'light' : 'dark', default: designTheme === 1 ? 'light' : 'dark',
// support: designTheme === 1 ? ['light'] : ['dark'],
}, },
mini: true, mini: true,
layout: 'wide', layout: 'wide',
@@ -54,7 +53,7 @@ onMounted(() => {
stoveGnbOptions stoveGnbOptions
) )
} }
if(mountedInstance){ if (mountedInstance) {
//Stove GNB에서도 쿠키를 굽고 있어 소문자로 통일 //Stove GNB에서도 쿠키를 굽고 있어 소문자로 통일
//LOCALE 쿠키를 가져온 후 소문자로 변경해서 다시 LOCALE 쿠키를 설정 //LOCALE 쿠키를 가져온 후 소문자로 변경해서 다시 LOCALE 쿠키를 설정
nextTick(() => { nextTick(() => {
@@ -66,10 +65,8 @@ onMounted(() => {
localeCookie.value = localeCookie.value.toLowerCase() localeCookie.value = localeCookie.value.toLowerCase()
}) })
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (mountedInstance && typeof mountedInstance.destroy === 'function') { if (mountedInstance && typeof mountedInstance.destroy === 'function') {
mountedInstance.destroy() mountedInstance.destroy()

View File

@@ -93,7 +93,7 @@ watch(isOpen, newVal => {
:class="[ :class="[
'content-tex', 'content-tex',
'use-base', 'use-base',
{ 'is-visible': isVisible(index) }, { 'is-hidden': !isVisible(index) },
]" ]"
/> />
</template> </template>
@@ -153,13 +153,4 @@ watch(isOpen, newVal => {
.content-tex { .content-tex {
@apply overflow-y-auto mb-4 px-6 sm:mb-6 sm:px-8; @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> </style>

View File

@@ -50,7 +50,6 @@ const mainOptions = computed<Options>(() => ({
const thumbOptions = computed<Options>(() => ({ const thumbOptions = computed<Options>(() => ({
type: 'slide', type: 'slide',
rewind: true, rewind: true,
// focus: 'center',
autoWidth: true, autoWidth: true,
perMove: 1, perMove: 1,
arrows: true, arrows: true,

View File

@@ -6,9 +6,6 @@ import type {
const { locale } = useI18n() const { locale } = useI18n()
const gameDomain = useGetGameDomain() const gameDomain = useGetGameDomain()
const scrollStore = useScrollStore()
const { isPassedStoveGnb } = storeToRefs(scrollStore)
const isEventNavigationOpen = ref(true) const isEventNavigationOpen = ref(true)
const eventNavigationList = ref<Record<string, EventNavigation>>({}) const eventNavigationList = ref<Record<string, EventNavigation>>({})
@@ -52,7 +49,6 @@ onMounted(async () => {
class="event-navigation" class="event-navigation"
:class="{ :class="{
'is-closed': !isEventNavigationOpen, 'is-closed': !isEventNavigationOpen,
'is-fixed': isPassedStoveGnb,
}" }"
> >
<div class="navigation-wrapper"> <div class="navigation-wrapper">
@@ -90,10 +86,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.event-navigation { .event-navigation {
@apply absolute top-0 left-0 bottom-0 mt-[48px] md:mt-[64px] z-[100] transition-transform duration-300 ease-in-out; @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;
}
.event-navigation.is-fixed {
@apply fixed;
} }
.navigation-wrapper { .navigation-wrapper {
@apply relative h-full p-3 sm:p-5 sm:pr-3 @apply relative h-full p-3 sm:p-5 sm:pr-3

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { onClickOutside, useWindowSize } from '@vueuse/core'
import { useGameDataStore } from '#layers/stores/useGameDataStore' import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type { import type {
GameDataMenu, GameDataMenu,
@@ -9,6 +8,14 @@ import type {
PlatformTransformType, PlatformTransformType,
} from '#layers/types/api/gameData' } from '#layers/types/api/gameData'
const MORE_WIDTH = 72
const START_WIDTH_MARGIN = 40
const PLATFORM_LABEL_KEY: Record<PlatformTransformType, string> = {
pc: 'PC',
google_play: 'Google Play',
app_store: 'App Store',
}
const route = useRoute() const route = useRoute()
const { tm } = useI18n() const { tm } = useI18n()
const { width } = useWindowSize() const { width } = useWindowSize()
@@ -21,21 +28,23 @@ const modalStore = useModalStore()
const { gameData } = storeToRefs(gameDataStore) const { gameData } = storeToRefs(gameDataStore)
const { isPassedStoveGnb } = storeToRefs(scrollStore) const { isPassedStoveGnb } = storeToRefs(scrollStore)
const navAreaRef = ref<HTMLElement>() const navAreaRef = ref<HTMLElement | null>(null)
const startRef = ref<HTMLElement>() const startRef = ref<HTMLElement | null>(null)
const gnbData = gameData.value?.gnb const { width: startWidth } = useElementSize(startRef)
const isMounted = ref(false)
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
const navWidth = ref(0) const navWidth = ref(0)
const startWidth = ref(0)
const officialItemWidths = ref<number[]>([]) const officialItemWidths = ref<number[]>([])
const overflowNam = ref<number>(0) const overflowNam = ref<number>(0)
const gnbData = computed(() => gameData.value?.gnb)
const gnb1depthButtonData = computed( const gnb1depthButtonData = computed(
() => gnbData?.buttons[0]?.button_json as GameDataResourceGroup () => gnbData.value?.buttons[0]?.button_json as GameDataResourceGroup
) )
const gnb2depthButtonData = computed( const gnb2depthButtonData = computed(
() => gnbData?.buttons[1]?.button_json as GameDataResourceGroupSet () => gnbData.value?.buttons[1]?.button_json as GameDataResourceGroupSet
) )
const currentPath = computed(() => formatPathWithoutLocale(route.path)) const currentPath = computed(() => formatPathWithoutLocale(route.path))
const supportedPlatforms = computed( const supportedPlatforms = computed(
@@ -47,7 +56,7 @@ const supportedPlatforms = computed(
const pathMatches = (base: string, current: string) => { const pathMatches = (base: string, current: string) => {
if (!base || base === '/') return current === '/' if (!base || base === '/') return current === '/'
return current === base || current.startsWith(base + '/') return current === base
} }
/** 자식 중 활성 링크 존재 여부 */ /** 자식 중 활성 링크 존재 여부 */
@@ -76,20 +85,10 @@ const isNavItemActive = (gnbItem: GameDataMenu): boolean => {
// navAreaRef의 넓이를 구하는 함수 // navAreaRef의 넓이를 구하는 함수
const calculateNavWidth = () => { const calculateNavWidth = () => {
if (!navAreaRef.value || !gnbData) return 0 if (!navAreaRef.value || !gnbData.value) return 0
const navAreaWidth = navAreaRef.value.offsetWidth const navAreaWidth = navAreaRef.value.offsetWidth
const moreWidth = 72 // 더보기 버튼 넓이 + 마진 navWidth.value = navAreaWidth + MORE_WIDTH
return navAreaWidth + moreWidth
}
// startRef의 넓이를 구하는 함수
const calculateStartWidth = () => {
if (!startRef.value || !gnbData) return 0
const startWidth = startRef.value.offsetWidth
const headerRightPadding = 40 // 헤더 오른쪽 마진
return startWidth + headerRightPadding
} }
// official 자식들의 넓이를 구하는 함수 // official 자식들의 넓이를 구하는 함수
@@ -108,24 +107,20 @@ const calculateOfficialItemWidths = () => {
} }
officialItemWidths.value = widths officialItemWidths.value = widths
// 해상도 체크 및 오버플로우 계산
calculateOverflow()
} }
// 오버플로우 계산 함수 // 오버플로우 계산 함수
const calculateOverflow = () => { const calculateOverflow = () => {
if (!navAreaRef.value || !startRef.value) return if (!navAreaRef.value || !startRef.value) return
const totalNavWidth = navWidth.value + startWidth.value if (breakpoints.value.isMobile) {
const screenWidth = width.value
// 모바일(1024px 미만)에서는 overflowNam을 0으로 설정
if (screenWidth < 1024) {
overflowNam.value = 0 overflowNam.value = 0
return return
} }
const screenWidth = width.value
const totalNavWidth = navWidth.value + startWidth.value + START_WIDTH_MARGIN
// 해상도가 navWidth + startWidth보다 작은 경우 // 해상도가 navWidth + startWidth보다 작은 경우
if (screenWidth < totalNavWidth) { if (screenWidth < totalNavWidth) {
let removedCount = 0 let removedCount = 0
@@ -166,12 +161,6 @@ const has2depthButton = (gnbItem: GameDataMenu) => {
const highlight = (text: string) => `<span class="highlight">${text}</span>` 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 tmWithGameName = (key: string): string => {
const raw = tm(key) const raw = tm(key)
if (typeof raw !== 'string') return '' if (typeof raw !== 'string') return ''
@@ -215,7 +204,10 @@ const handleStartClick = () => {
window.open(url, '_blank') window.open(url, '_blank')
} }
const stopClickOutside = onClickOutside(navAreaRef, () => handleMenuClose()) watchEffect(() => {
if (!startWidth.value) return // 0, null, undefined면 스킵
calculateOverflow()
})
// 화면 크기 변경 시 오버플로우 재계산 // 화면 크기 변경 시 오버플로우 재계산
watch(width, () => { watch(width, () => {
@@ -224,51 +216,47 @@ watch(width, () => {
onMounted(() => { onMounted(() => {
overflowNam.value = 0 overflowNam.value = 0
isMounted.value = true
// 초기 계산 시도
nextTick(() => { nextTick(() => {
if (navAreaRef.value && startRef.value) { calculateNavWidth()
navWidth.value = calculateNavWidth() calculateOfficialItemWidths()
startWidth.value = calculateStartWidth() calculateOverflow()
calculateOfficialItemWidths()
}
}) })
}) })
onBeforeUnmount(() => {
if (stopClickOutside) {
stopClickOutside()
}
})
</script> </script>
<template> <template>
<header class="header"> <header class="header">
<BlocksStoveGnbNew class="h-[48px]" /> <BlocksStoveGnbNew class="h-[48px]" />
<div :class="['game-wrap', { 'is-fixed': isPassedStoveGnb }]"> <div :class="['game-wrap', { 'is-fixed': isPassedStoveGnb }]">
<NuxtLinkLocale to="/brand" class="mx-auto md:hidden router-link-active router-link-exact-active"> <AtomsLocaleLink to="/" class="mx-auto md:hidden">
<img <img
:src="getImageHost(gnbData?.bi_path)" :src="getImageHost(gnbData?.bi_path)"
:alt="gameData?.game_name" :alt="gameData?.game_name"
class="h-[30px]" class="h-[30px]"
/> />
</NuxtLinkLocale> </atomslocalelink>
<button class="btn-open" @click="handleMenuOpen"> <button class="btn-open" @click="handleMenuOpen">
<AtomsIconsMenuBoldLine class="mx-auto" /> <AtomsIconsMenuBoldLine class="mx-auto" />
<span class="sr-only">menu open</span> <span class="sr-only">menu open</span>
</button> </button>
<div :class="['nav-wrap', { 'is-open': isMenuOpen }]"> <div
<div ref="navAreaRef" class="nav-area"> :class="['nav-wrap', { 'is-open': isMenuOpen }]"
@click="handleMenuClose"
>
<div ref="navAreaRef" class="nav-area" @click.stop>
<div class="nav-logo"> <div class="nav-logo">
<NuxtLinkLocale to="/brand" class="router-link-active router-link-exact-active" @click="handleMenuClose"> <AtomsLocaleLink to="/" @click="handleMenuClose">
<img <img
:src="getImageHost(gnbData?.bi_path)" :src="getImageHost(gnbData?.bi_path)"
:alt="gameData?.game_name" :alt="gameData?.game_name"
class="h-[30px]" class="h-[30px]"
/> />
</NuxtLinkLocale> </AtomsLocaleLink>
</div> <nav :class="['nav-list', { 'is-mounted': isMounted }]">
<nav class="nav-list"> <div v-if="gnbData?.menus" class="official custom-theme-scrollbar">
<div v-if="gnbData?.menus" class="official">
<div <div
v-for="(gnbItem, key) in gnbData?.menus" v-for="(gnbItem, key) in gnbData?.menus"
:key="key" :key="key"
@@ -376,45 +364,48 @@ onBeforeUnmount(() => {
class="nav-1depth text-gradient-pink" class="nav-1depth text-gradient-pink"
> >
<AtomsIconsStarFill /> <AtomsIconsStarFill />
<span>이벤트</span> <span>{{ tm('Gnb_Event') }}</span>
<AtomsIconsStarFill /> <AtomsIconsStarFill />
</AtomsLocaleLink> </AtomsLocaleLink>
</div> </div>
</div> </div>
</nav> </nav>
<div ref="startRef" class="btn-start"> <ClientOnly>
<ClientOnly> <div ref="startRef" class="btn-start">
<component <template v-if="gnb1depthButtonData">
:is=" <component
breakpoints.isDesktop ? 'BlocksButtonLauncher' : 'AtomsButton' :is="
" breakpoints.isDesktop
type="custom" ? 'BlocksButtonLauncher'
platform="pc" : 'AtomsButton'
:background-color=" "
getColorCodeFromData(gnb1depthButtonData?.btn_info, 'btn') type="custom"
" platform="pc"
:text-color=" :background-color="
getColorCodeFromData(gnb1depthButtonData?.btn_info, 'txt') getColorCodeFromData(gnb1depthButtonData?.btn_info, 'btn')
" "
@click="handleStartClick" :text-color="
> getColorCodeFromData(gnb1depthButtonData?.btn_info, 'txt')
{{ gnb1depthButtonData?.btn_info?.txt_btn_name }} "
</component> @click="handleStartClick"
>
<div {{ gnb1depthButtonData?.btn_info?.txt_btn_name }}
v-if="breakpoints.isDesktop && gnb2depthButtonData" </component>
class="nav-2depth" <div
> v-if="breakpoints.isDesktop && gnb2depthButtonData"
<ul> class="nav-2depth"
<li v-for="(item, key) in gnb2depthButtonData" :key="key"> >
<BlocksButtonLauncher type="custom" :platform="key"> <ul>
{{ item.btn_info?.txt_btn_name }} <li v-for="(item, key) in gnb2depthButtonData" :key="key">
</BlocksButtonLauncher> <BlocksButtonLauncher type="custom" :platform="key">
</li> {{ item.btn_info?.txt_btn_name }}
</ul> </BlocksButtonLauncher>
</div> </li>
</ClientOnly> </ul>
</div> </div>
</template>
</div>
</ClientOnly>
<button class="btn-close" @click="handleMenuClose"> <button class="btn-close" @click="handleMenuClose">
<AtomsIconsCloseLine <AtomsIconsCloseLine
size="24" size="24"
@@ -426,12 +417,12 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
</div> </div>
</header> </div></header>
</template> </template>
<style scoped> <style scoped>
.header { .header {
@apply bg-theme-foreground text-theme-foreground-reversal relative z-[200]; @apply bg-theme-foreground text-theme-foreground-reversal relative font-[500] tracking-[-0.48px] z-[200];
} }
.game-wrap { .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] @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]
@@ -471,7 +462,7 @@ onBeforeUnmount(() => {
.nav-area { .nav-area {
@apply flex flex-col w-[100vw] max-w-[360px] min-w-[320px] bg-theme-foreground-10 translate-x-[-100%] @apply flex flex-col w-[100vw] max-w-[360px] min-w-[320px] bg-theme-foreground-10 translate-x-[-100%]
md:inline-flex md:flex-row md:w-auto md:max-w-none md:h-full md:pl-[40px] md:items-center md:bg-transparent md:transform-none; md:inline-flex md:flex-row md:w-auto md:max-w-[100%] md:h-full md:pl-[40px] md:items-center md:bg-transparent md:transform-none;
} }
.nav-logo { .nav-logo {
@@ -480,7 +471,10 @@ onBeforeUnmount(() => {
.nav-list { .nav-list {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2 @apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2
md:flex-row md:order-none md:h-full md:my-0 md:ml-10 md:mr-6 md:px-0 md:overflow-visible; md:flex-row md:order-none md:h-full md:my-0 md:ml-10 md:mr-6 md:px-0;
}
.nav-list.is-mounted {
@apply md:overflow-visible;
} }
.nav-item { .nav-item {
@@ -528,6 +522,12 @@ onBeforeUnmount(() => {
.official { .official {
@apply overflow-x-hidden overflow-y-auto pb-2 md:flex md:items-center md:space-x-8 md:pb-0 md:overflow-visible; @apply overflow-x-hidden overflow-y-auto pb-2 md:flex md:items-center md:space-x-8 md:pb-0 md:overflow-visible;
} }
.custom-theme-scrollbar::-webkit-scrollbar {
@apply w-1;
}
.custom-theme-scrollbar::-webkit-scrollbar-thumb {
@apply border-0;
}
.more { .more {
@apply relative hidden ml-[32px] pt-[11px] md:block; @apply relative hidden ml-[32px] pt-[11px] md:block;

View File

@@ -14,6 +14,6 @@ const props = defineProps<{
<style scoped> <style scoped>
.description { .description {
@apply line-clamp-4 text-[15px] font-[400] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-3 md:text-[20px] md:leading-[30px]; @apply line-clamp-4 text-[15px] font-[400] tracking-[-0.45px] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:tracking-[-0.6px] md:line-clamp-3 md:text-[20px] md:leading-[30px];
} }
</style> </style>

View File

@@ -41,13 +41,13 @@ const componentProps = computed(() => {
</script> </script>
<template> <template>
<div class="flex flex-wrap items-end justify-between mb-[16px] md:mb-[24px]"> <div class="flex items-end justify-between mb-[16px] md:mb-[24px]">
<h3 <h3
class="text-[#1F1F1F] text-[18px] font-bold leading-[26px] tracking-[-0.54px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px] shrink-0" class="text-[#1F1F1F] text-[18px] font-bold leading-[26px] tracking-[-0.54px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px] shrink-0"
> >
<span>{{ props.title }}</span> <span>{{ props.title }}</span>
</h3> </h3>
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between">
<slot /> <slot />
<p <p
v-if="props.description && !props.link" v-if="props.description && !props.link"

View File

@@ -280,7 +280,7 @@ defineExpose({
class="absolute left-0 right-0 top-0 bg-gradient-to-b from-[#292929] to-transparent z-[1] h-[24px] md:h-[32px]" class="absolute left-0 right-0 top-0 bg-gradient-to-b from-[#292929] to-transparent z-[1] h-[24px] md:h-[32px]"
></div> ></div>
<div <div
class="overflow-y-auto h-full py-[24px] px-5 md:py-[32px] md:px-10" class="overflow-y-auto h-full py-[24px] px-5 md:py-[32px] md:px-10 custom-theme-scrollbar"
> >
<div class="px-3 py-4 md:px-6"> <div class="px-3 py-4 md:px-6">
<div class="flex cursor-pointer items-center gap-3 md:gap-4"> <div class="flex cursor-pointer items-center gap-3 md:gap-4">
@@ -308,7 +308,7 @@ defineExpose({
<!-- Marketing Detail Content --> <!-- Marketing Detail Content -->
<div <div
v-if="isExpandedMarketing" v-if="isExpandedMarketing"
class="mt-4 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3" class="mt-4 max-h-[160px] overflow-y-auto rounded-lg bg-white/[0.04] px-4 py-3 custom-theme-scrollbar"
> >
<p <p
v-dompurify-html="tmWithGameName('Preregist_Agree_News_Info')" v-dompurify-html="tmWithGameName('Preregist_Agree_News_Info')"
@@ -426,27 +426,4 @@ defineExpose({
.modal-wrap:deep(.modal-close) svg { .modal-wrap:deep(.modal-close) svg {
@apply fill-white; @apply fill-white;
} }
/* Custom scrollbar for accordion content */
:deep(.overflow-y-auto) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
:deep(.overflow-y-auto::-webkit-scrollbar) {
width: 4px;
}
:deep(.overflow-y-auto::-webkit-scrollbar-track) {
background: transparent;
}
:deep(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
}
:deep(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.25);
}
</style> </style>

View File

@@ -10,12 +10,13 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const serverGameData = getGameDataFromServer() const serverGameData = getGameDataFromServer()
const gameDataStore = useGameDataStore() const gameDataStore = useGameDataStore()
const { setGameData } = gameDataStore const { setGameData } = gameDataStore
if (serverGameData) { if (serverGameData) {
setGameData(serverGameData) setGameData(serverGameData)
} }
try{ try {
// 서버 사이드에서는 스킵 // 서버 사이드에서는 스킵
if (!import.meta.client) { if (!import.meta.client) {
return return
@@ -34,7 +35,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
// 쿼리스트링에서 f 파라미터 값 추출 (CSR용) // 쿼리스트링에서 f 파라미터 값 추출 (CSR용)
// const fValue = (to.query.f as string) || '' // const fValue = (to.query.f as string) || ''
// 미리보기 API 호출 처리 // 미리보기 API 호출 처리
// let finalGameDomain = currentDomain // let finalGameDomain = currentDomain
// if (fValue === 'preview') { // if (fValue === 'preview') {
@@ -52,9 +53,12 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
// const { getGameDataExternal } = useGetGameDataExternal() // const { getGameDataExternal } = useGetGameDataExternal()
// await getGameDataExternal(req) // await getGameDataExternal(req)
// error 페이지는 API 호출하지 않음 // error 페이지는 API 호출하지 않음
if (pageUrl === '/error' || to.path.includes('/error') || to.path.includes('/inspection')) { if (
pageUrl === '/error' ||
to.path.includes('/error') ||
to.path.includes('/inspection')
) {
return return
} }
@@ -64,17 +68,17 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
// 현재 언어가 허용된 언어 목록에 없으면 에러 페이지로 이동 // 현재 언어가 허용된 언어 목록에 없으면 에러 페이지로 이동
if (currentLangCode && !allowedLangCodes.includes(currentLangCode)) { if (currentLangCode && !allowedLangCodes.includes(currentLangCode)) {
return navigateTo(`/${currentLangCode}/error`, { external: true }) return navigateTo(`/${currentLangCode}/error`, { external: true })
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
showError(createError({ showError(
statusCode: error.statusCode, createError({
statusMessage: error.message, statusCode: error?.statusCode || error?.status || 500,
fatal: false, // 즉시 에러 페이지로 statusMessage:
data: { reason: 'post-not-found' } error?.statusMessage || error?.message || 'Internal Server Error',
})) fatal: false, // 즉시 에러 페이지로
data: { reason: 'post-not-found' },
})
)
} }
}) })

View File

@@ -20,11 +20,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
const loadingStore = useLoadingStore() const loadingStore = useLoadingStore()
const { getPathAfterLanguage } = usePathResolver() const { getPathAfterLanguage } = usePathResolver()
const langCode = csrGetFinalLocale(to.path, gameData.value?.lang_codes)
const langCode = csrGetFinalLocale(
to.path,
gameData.value?.lang_codes,
)
try { try {
if (to.path.includes('inspection')) { if (to.path.includes('inspection')) {
@@ -33,18 +29,16 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
} }
const pageUrl = getPathAfterLanguage(to.path) const pageUrl = getPathAfterLanguage(to.path)
console.log("🚀 ~ pageUrl:", pageUrl)
// pageUrl이 빈값이거나 null이면 /brand로 리다이렉트 // pageUrl이 빈값이거나 null이면 /brand로 리다이렉트
if ( // if (
!pageUrl || // !pageUrl ||
pageUrl === '' || // pageUrl === '' ||
pageUrl === '/' || // pageUrl === '/' ||
pageUrl === `/${langCode}/` // pageUrl === `/${langCode}/`
) { // ) {
return navigateTo(`/${langCode}/brand`, { external: false }) // return navigateTo(`/${langCode}/brand`, { external: false })
} // }
// error 페이지는 API 호출하지 않음 // error 페이지는 API 호출하지 않음
if (pageUrl === '/error' || to.path.includes('/error')) { if (pageUrl === '/error' || to.path.includes('/error')) {
@@ -61,7 +55,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
// 쿼리스트링에서 f 파라미터 값 추출 (CSR용) // 쿼리스트링에서 f 파라미터 값 추출 (CSR용)
// const fValue = (to.query.f as string) || '' // const fValue = (to.query.f as string) || ''
// // 미리보기 API 호출 처리 // // 미리보기 API 호출 처리
// let finalGameDomain = gameDomain // let finalGameDomain = gameDomain
// if (fValue === 'preview') { // if (fValue === 'preview') {

View File

@@ -67,7 +67,11 @@ const cache = new LRUCache({
* @param finalLocale - 최종 언어 * @param finalLocale - 최종 언어
* @param baseDomain - 기본 도메인 * @param baseDomain - 기본 도메인
*/ */
function setFinalLocaleCookie(event: any, finalLocale: string, baseDomain: string) { function setFinalLocaleCookie(
event: any,
finalLocale: string,
baseDomain: string
) {
setCookie(event, 'LOCALE', finalLocale.toLowerCase(), { setCookie(event, 'LOCALE', finalLocale.toLowerCase(), {
domain: baseDomain, domain: baseDomain,
path: '/', path: '/',
@@ -95,7 +99,6 @@ function fnLocaleMiddleware(event: any, finalLocale: string) {
// 쿼리스트링 포함 시 순수 경로만 추출 // 쿼리스트링 포함 시 순수 경로만 추출
arrPath = path.split('?')[0].split('/') arrPath = path.split('?')[0].split('/')
queryString = path.split('?')[1] queryString = path.split('?')[1]
} else { } else {
arrPath = path.split('/') arrPath = path.split('/')
queryString = '' queryString = ''
@@ -134,7 +137,7 @@ export default defineEventHandler(async event => {
if (event.node.res.headersSent || event.node.res.writableEnded) { if (event.node.res.headersSent || event.node.res.writableEnded) {
return return
} }
// const runType = `${config.public.runType}` // const runType = `${config.public.runType}`
// console.log("🚀 ~ baseDomain:", config.public.baseDomain) // console.log("🚀 ~ baseDomain:", config.public.baseDomain)
// const url = getRequestURL(event) // const url = getRequestURL(event)
@@ -235,9 +238,14 @@ export default defineEventHandler(async event => {
if (event.node.res.headersSent || event.node.res.writableEnded) { if (event.node.res.headersSent || event.node.res.writableEnded) {
return return
} }
// 2. 언어 코드 추출 // 2. 언어 코드 추출
finalLocale = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers, initLangCodes, initDefaultLocale) finalLocale = ssrGetFinalLocale(
event?.node.req.url,
event.node.req.headers,
initLangCodes,
initDefaultLocale
)
const path = event?.node.req.url || '' const path = event?.node.req.url || ''
let queryStringF = '' let queryStringF = ''
@@ -255,21 +263,22 @@ export default defineEventHandler(async event => {
console.error('쿼리스트링 파싱 에러:', e) console.error('쿼리스트링 파싱 에러:', e)
} }
} }
// 테스트용 500 에러 발생 // 테스트용 500 에러 발생
if (test500) { if (test500) {
throw new Error('테스트용 500 에러 발생') throw new Error('테스트용 500 에러 발생')
} }
// 미리보기 API 호출 처리 // 미리보기 API 호출 처리
if (fValue === 'preview') { if (fValue === 'preview') {
cleanHost = 'samplegame.onstove.com' cleanHost = 'samplegame.onstove.com'
} }
const queryParams: Record<string, string> = { const queryParams: Record<string, string> = {
game_domain: cleanHost || '', game_domain: cleanHost || '',
lang_code: finalLocale, lang_code: finalLocale,
} }
const response = (await $fetch(apiUrl, { const response = (await $fetch(apiUrl, {
query: queryParams, query: queryParams,
})) as GameDataResponse | null })) as GameDataResponse | null
@@ -283,7 +292,7 @@ export default defineEventHandler(async event => {
event.context.gameData = response.value event.context.gameData = response.value
event.context.googleAnalyticsId = response.value?.ga_code event.context.googleAnalyticsId = response.value?.ga_code
// console.log('🚀 ~ gameData:', response.value) console.log('🚀 ~ gameData:', response.value)
// 점검 데이터 조회 // 점검 데이터 조회
let inspectionData let inspectionData
@@ -305,7 +314,6 @@ export default defineEventHandler(async event => {
) )
inspectionData = inspectionResponse?.value?.inspection inspectionData = inspectionResponse?.value?.inspection
cache.set(cacheKey, inspectionData) // 캐시에 저장 cache.set(cacheKey, inspectionData) // 캐시에 저장
// console.log("🚀 ~ inspectionData:", inspectionData)
} }
} }
@@ -346,7 +354,10 @@ export default defineEventHandler(async event => {
// 허용된 IP 목록 확인 // 허용된 IP 목록 확인
if (!inspectionData?.ip_filter_list?.includes(clientIP)) { if (!inspectionData?.ip_filter_list?.includes(clientIP)) {
// 허용되지 않은 IP인 경우 점검 페이지로 이동 // 허용되지 않은 IP인 경우 점검 페이지로 이동
if (!event.node.res.headersSent && !event.node.res.writableEnded) { if (
!event.node.res.headersSent &&
!event.node.res.writableEnded
) {
event.node.res.statusCode = 302 event.node.res.statusCode = 302
event.node.res.setHeader('Location', inspectionPath) event.node.res.setHeader('Location', inspectionPath)
event.node.res.end() event.node.res.end()
@@ -404,6 +415,28 @@ export default defineEventHandler(async event => {
} }
} }
} }
// -------------------------------------------------------------------------------
// [Root Path Redirect to /home]
// 언어 코드만 있는 경로(예: /ko, /ko/)를 /home 리다이렉트
// -------------------------------------------------------------------------------
const normalizedPath = fullPath.endsWith('/')
? fullPath.slice(0, -1)
: fullPath
const localePath = `/${finalLocale}`
if (normalizedPath === localePath) {
const defaultPath = `/${finalLocale}/home`
const queryString = event?.node.req.url?.includes('?')
? '?' + event.node.req.url.split('?')[1]
: ''
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
event.node.res.statusCode = 302
event.node.res.setHeader('Location', defaultPath + queryString)
event.node.res.end()
return
}
}
// ------------------------------------------------------------------------------- // -------------------------------------------------------------------------------
// [Locale Middleware] // [Locale Middleware]
// ------------------------------------------------------------------------------- // -------------------------------------------------------------------------------
@@ -412,7 +445,7 @@ export default defineEventHandler(async event => {
} }
} catch (error) { } catch (error) {
console.error('gameData load error:', error) console.error('gameData load error:', error)
// 500 에러 발생 시 /error 페이지로 리다이렉트 // 500 에러 발생 시 /error 페이지로 리다이렉트
if (!event.node.res.headersSent && !event.node.res.writableEnded) { if (!event.node.res.headersSent && !event.node.res.writableEnded) {
// 언어 코드 추출 시도 // 언어 코드 추출 시도
@@ -429,7 +462,7 @@ export default defineEventHandler(async event => {
} }
// finalLocale이 undefined인 경우 기본값으로 'ko' 설정 // finalLocale이 undefined인 경우 기본값으로 'ko' 설정
console.log("🚀 ~ 여기도 타? error:", error) console.log('🚀 ~ 여기도 타? error:', error)
throw createError({ throw createError({
statusCode: error.statusCode, statusCode: error.statusCode,
statusMessage: error.statusMessage, statusMessage: error.statusMessage,

View File

@@ -135,6 +135,7 @@ const handleLoadMoreRecent = () => {
class="relative content-static bg-[#fff] rounded-[12px] md:rounded-[16px]" class="relative content-static bg-[#fff] rounded-[12px] md:rounded-[16px]"
> >
<BlocksSlideFade <BlocksSlideFade
:arrows="recommendedVideos.length > 1"
:pagination="false" :pagination="false"
:drag="false" :drag="false"
@move="handleSplideMove" @move="handleSplideMove"

View File

@@ -70,9 +70,11 @@ const onArrowClick = (direction, targetIndex) => {
</script> </script>
<template> <template>
<section class="pt-[80px] pb-[100px] md:pt-[120px] md:pb-[140px]"> <section class="section-standard">
<WidgetsBackground v-if="backgroundData" :resources-data="backgroundData" /> <WidgetsBackground v-if="backgroundData" :resources-data="backgroundData" />
<div class="content-standard px-0 max-w-[2043px] mx-auto"> <div
class="content-standard justify-start pt-[80px] px-0 max-w-[2043px] mx-auto md:pt-[120px]"
>
<WidgetsMainTitle <WidgetsMainTitle
v-if="mainTitleData" v-if="mainTitleData"
:resources-data="mainTitleData" :resources-data="mainTitleData"

View File

@@ -36,8 +36,8 @@ const onArrowClick = direction => {
<section class="section-standard"> <section class="section-standard">
<BlocksSlideFade <BlocksSlideFade
v-if="slideData" v-if="slideData"
:arrows="true" :arrows="slideData.length > 1"
:pagination="true" :pagination="slideData.length > 1"
class="h-full" class="h-full"
:pagination-data="paginationData" :pagination-data="paginationData"
@arrow-click="onArrowClick" @arrow-click="onArrowClick"

View File

@@ -212,8 +212,8 @@ export type StoveGnbSkinType =
// Stove GNB 타입 // Stove GNB 타입
export interface GameDataStoveGnb { export interface GameDataStoveGnb {
skin_type: StoveGnbSkinType notify_icon_visible: boolean
stove_install_button_visible: string stove_install_button_visible: boolean
} }
// 이벤트 배너 타입 // 이벤트 배너 타입

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB