Files
web-temp/layers/components/layouts/Header.vue
2025-12-01 14:23:35 +09:00

606 lines
18 KiB
Vue

<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type {
GameDataMenu,
GameDataMenuChildren,
GameDataResourceGroup,
GameDataResourceGroupSet,
PlatformTransformType,
} 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 { 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)
const navAreaRef = ref<HTMLElement | null>(null)
const startRef = ref<HTMLElement | null>(null)
const { width: startWidth } = useElementSize(startRef)
const isMounted = ref(false)
const isMenuOpen = ref(false)
const navWidth = ref(0)
const officialItemWidths = ref<number[]>([])
const overflowNam = ref<number>(0)
const gnbData = computed(() => gameData.value?.gnb)
const gnb1depthButtonData = computed(
() => gnbData.value?.buttons[0]?.button_json as GameDataResourceGroup
)
const gnb2depthButtonData = computed(
() => gnbData.value?.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 === '/'
return current === base
}
/** 자식 중 활성 링크 존재 여부 */
const hasActiveChild = (children?: GameDataMenuChildren) => {
const cur = currentPath.value
return formatToArray(children).some(child => {
if (!child?.url_path || !isInternalUrl(child.url_path)) return false
return pathMatches(formatPathWithoutLocale(child.url_path), cur)
})
}
/** 1Depth 활성화 여부 */
const isNavItemActive = (gnbItem: GameDataMenu): boolean => {
const cur = currentPath.value
const base = gnbItem?.url_path
const selfActive =
!!base &&
isInternalUrl(base) &&
pathMatches(formatPathWithoutLocale(base), cur)
return selfActive || hasActiveChild(gnbItem.children)
}
// navAreaRef의 넓이를 구하는 함수
const calculateNavWidth = () => {
if (!navAreaRef.value || !gnbData.value) return 0
const navAreaWidth = navAreaRef.value.offsetWidth
navWidth.value = navAreaWidth + MORE_WIDTH
}
// official 자식들의 넓이를 구하는 함수
const calculateOfficialItemWidths = () => {
if (!navAreaRef.value) return
const officialItems = navAreaRef.value.querySelectorAll('.official .nav-item')
const widths: number[] = []
// 뒤에서부터 순서대로 넓이 계산
for (let i = officialItems.length - 1; i >= 0; i--) {
const item = officialItems[i] as HTMLElement
if (item) {
widths.push(item.offsetWidth)
}
}
officialItemWidths.value = widths
}
// 오버플로우 계산 함수
const calculateOverflow = () => {
if (!navAreaRef.value || !startRef.value) return
if (breakpoints.value.isMobile) {
overflowNam.value = 0
return
}
const screenWidth = width.value
const totalNavWidth = navWidth.value + startWidth.value + START_WIDTH_MARGIN
// 해상도가 navWidth + startWidth보다 작은 경우
if (screenWidth < totalNavWidth) {
let removedCount = 0
let currentTotal = totalNavWidth
for (let i = 0; i < officialItemWidths.value.length; i++) {
currentTotal -= officialItemWidths.value[i]
removedCount++
if (currentTotal <= screenWidth) {
break
}
}
overflowNam.value = removedCount
} else {
overflowNam.value = 0
}
}
const handleMenuOpen = () => {
isMenuOpen.value = true
scrollStore.controlScrollLock(true)
}
const handleMenuClose = () => {
isMenuOpen.value = false
scrollStore.controlScrollLock(false)
}
const isNotClickable = (gnbItem: GameDataMenu) => {
return gnbItem.click_action_type === 0
}
const has2depthButton = (gnbItem: GameDataMenu) => {
return gnbItem.children && Object.keys(gnbItem.children).length > 0
}
const highlight = (text: string) => `<span class="highlight">${text}</span>`
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')
}
watchEffect(() => {
if (!startWidth.value) return // 0, null, undefined면 스킵
calculateOverflow()
})
// 화면 크기 변경 시 오버플로우 재계산
watch(width, () => {
calculateOverflow()
})
onMounted(() => {
overflowNam.value = 0
isMounted.value = true
// 초기 계산 시도
nextTick(() => {
calculateNavWidth()
calculateOfficialItemWidths()
calculateOverflow()
})
})
</script>
<template>
<header :class="['header', { 'empty-game': !gnbData }]">
<BlocksStoveGnbNew class="h-[48px]" />
<div
v-if="gnbData"
:class="['game-wrap', { 'is-fixed': isPassedStoveGnb }]"
>
<AtomsLocaleLink to="/" class="mx-auto md:hidden">
<img
:src="formatPathHost(gnbData?.bi_path)"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
<button class="btn-open" @click="handleMenuOpen">
<AtomsIconsMenuBoldLine class="mx-auto" />
<span class="sr-only">menu open</span>
</button>
<div
:class="['nav-wrap', { 'is-open': isMenuOpen }]"
@click="handleMenuClose"
>
<div ref="navAreaRef" class="nav-area" @click.stop>
<div class="nav-logo">
<AtomsLocaleLink to="/" @click="handleMenuClose">
<img
:src="formatPathHost(gnbData?.bi_path)"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
</div>
<nav :class="['nav-list', { 'is-mounted': isMounted }]">
<div v-if="gnbData?.menus" class="official custom-theme-scrollbar">
<div
v-for="(gnbItem, key) in gnbData?.menus"
:key="key"
class="nav-item"
:class="{
'is-hidden':
overflowNam > 0 &&
Number(key) >=
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<AtomsLocaleLink
:to="isNotClickable(gnbItem) ? '#' : gnbItem.url_path"
:target="gnbItem.link_target"
:class="[
'nav-1depth',
{ 'has-link': !isNotClickable(gnbItem) },
{ active: isNavItemActive(gnbItem) },
]"
@click="handleMenuClose"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="gnbItem.link_target === '_blank'"
/>
<AtomsIconsArrowDownFill
v-if="has2depthButton(gnbItem)"
class="hidden md:block"
/>
<AtomsIconsArrowRightLine
v-if="!has2depthButton(gnbItem)"
class="ml-auto md:hidden"
/>
</AtomsLocaleLink>
<Transition name="fade">
<div v-if="has2depthButton(gnbItem)" class="nav-2depth">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<AtomsLocaleLink
:to="child.url_path"
:target="child.link_target"
@click="handleMenuClose"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</AtomsLocaleLink>
</li>
</ul>
</div>
</Transition>
</div>
</div>
<div v-if="gnbData?.menus && overflowNam > 0" class="more">
<button class="btn-more">
<AtomsIconsOptionHorizontalFill class="mx-auto" />
<span class="sr-only">more</span>
</button>
<div class="more-list">
<div class="list-inner">
<div
v-for="(gnbItem, key) in gnbData?.menus"
:key="key"
:class="{
hidden:
Number(key) <
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<AtomsLocaleLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
@click="handleMenuClose"
>
<span>{{ gnbItem.menu_name }}</span>
</AtomsLocaleLink>
<div v-if="gnbItem.children">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<AtomsLocaleLink
:to="child.url_path"
:target="child.link_target"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</AtomsLocaleLink>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-if="gameData?.event_banner" class="event">
<div class="nav-item">
<AtomsLocaleLink
:to="gameData.event_banner?.page_url"
:target="
gameData.event_banner?.link_type === 1 ? '_self' : '_blank'
"
class="nav-1depth text-gradient-pink"
@click="handleMenuClose"
>
<AtomsIconsStarFill />
<span>{{ tm('Gnb_Event') }}</span>
<AtomsIconsStarFill />
<AtomsIconsArrowRightLine class="ml-auto md:hidden" />
</AtomsLocaleLink>
</div>
</div>
</nav>
<ClientOnly>
<div ref="startRef" class="btn-start">
<template v-if="gnb1depthButtonData">
<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>
</template>
</div>
</ClientOnly>
<button class="btn-close" @click="handleMenuClose">
<AtomsIconsCloseLine
size="24"
color="var(--foreground-reversal)"
class="mx-auto"
/>
<span class="sr-only">menu close</span>
</button>
</div>
</div>
</div>
</header>
</template>
<style scoped>
.header {
@apply bg-theme-foreground text-theme-foreground-reversal relative font-[500] tracking-[-0.48px] 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]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-theme-foreground-reversal-6;
}
.game-wrap.is-fixed {
@apply fixed top-0;
}
.game-logo {
@apply mx-auto shrink-0 md:mx-0;
}
.btn-open,
.btn-close {
@apply absolute w-[40px] h-[40px] md:hidden;
}
.btn-open {
@apply top-[4px] left-[12px] sm:left-[32px];
}
.btn-close {
@apply top-[11px] left-[12px];
}
.nav-wrap {
@apply fixed top-0 left-0 bottom-0 w-0 mt-[var(--scroll-position,48px)] md:relative md:w-full md:h-full md:mt-0;
}
.nav-wrap.is-open {
@apply w-full;
}
.nav-wrap.is-open::before {
@apply content-[''] absolute inset-0 w-[100vw] h-full bg-[rgba(0,0,0,0.6)] md:hidden;
}
.nav-wrap.is-open .nav-area {
@apply h-full translate-x-0 transition-transform duration-300 md:transform-none;
}
.nav-area {
@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-[100%] md:h-full md:pl-[40px] md:items-center md:bg-transparent md:transform-none;
}
.nav-logo {
@apply flex items-center justify-center shrink-0 h-[62px];
}
.nav-list {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-6 pl-2 pr-1
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 {
@apply relative flex flex-col text-[16px] md:items-center md:h-full;
}
.nav-item:last-of-type::after {
@apply hidden;
}
.nav-item::after {
@apply content-[''] h-px my-2 mx-3 bg-theme-foreground-reversal-8 md:hidden;
}
.nav-item:hover .nav-1depth::after,
.nav-1depth.active::after {
@apply opacity-100 md:transition-opacity;
}
.nav-item:hover .nav-2depth {
@apply md:block;
}
.nav-1depth {
@apply flex items-center py-[9px] px-3 gap-1 rounded-[12px] md:h-full md:py-0 md:px-0;
}
.nav-1depth::after {
@apply md:content-[''] md:absolute md:bottom-0 md:left-0 md:w-full md:h-[2px] md:bg-theme-foreground-reversal md:opacity-0;
}
.nav-1depth.active {
@apply bg-theme-foreground-reversal-8 md:bg-transparent;
}
.nav-1depth.has-link {
@apply cursor-pointer hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10 md:hover:bg-transparent md:active:bg-transparent;
}
.nav-2depth {
@apply text-[15px] md:hidden md:absolute md:top-[64px] md:left-[-28px] md:pt-1;
}
.nav-2depth ul {
@apply bg-theme-foreground-10 rounded-[20px] md:min-w-[190px] md:p-3 md:shadow-lg;
}
.nav-2depth a {
@apply flex items-center gap-1 py-[9px] px-5 rounded-[12px] transition-colors
md:py-[11px] md:px-4
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
.official {
@apply overflow-x-hidden overflow-y-scroll 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 {
@apply relative hidden ml-[32px] pt-[11px] md:block;
}
.more:hover .more-list {
@apply md:block;
}
.btn-more {
@apply w-[40px] h-[40px] rounded-[12px] bg-theme-foreground-reversal-6 hover:bg-theme-foreground-reversal-10 active:bg-theme-foreground-reversal-4;
}
.more-list {
@apply hidden absolute top-[64px] left-[-20px] pt-1;
}
.list-inner {
@apply min-w-[190px] p-3 rounded-[20px] bg-theme-foreground-10 shadow-lg;
}
.more-list a {
@apply flex items-center gap-1 py-[10px] px-4 rounded-[12px] transition-colors
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
.more-list li a {
@apply px-6;
}
.event {
@apply relative pr-1 md:ml-[64px] md:pr-0
before:content-[''] before:block before:h-px before:mb-2 before:mx-3 before:bg-theme-foreground-reversal-8 md:before:hidden
after:content-[''] after:absolute md:after:top-[50%] md:after:left-[-32px] md:after:w-[1px] md:after:h-[16px] md:after:bg-theme-foreground-gray-750 md:after:translate-y-[-50%];
}
.is-hidden {
@apply hidden;
}
.btn-start {
@apply relative py-2 px-5 md:absolute md:top-[0] md:right-0 md:flex md:items-center md:h-full md:py-0 md:px-0;
}
.btn-start:hover .nav-2depth {
@apply md:block;
}
.btn-start:deep(> .btn-base) {
@apply w-full h-[48px] px-10 font-[700];
}
.btn-start:deep(> .btn-base) .icon-platform {
@apply hidden;
}
.btn-start:deep(> .btn-base) .btn-content {
@apply justify-center;
}
.btn-start .nav-2depth {
@apply left-[unset] right-[-40px];
}
.btn-start .nav-2depth:deep(.btn-base) {
@apply w-full h-[48px] px-4 bg-transparent before:hidden after:hidden
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
.btn-start .nav-2depth:deep(.btn-base) .text {
@apply ml-1.5 text-[15px] text-theme-foreground-reversal;
}
[data-theme='light'] {
.btn-start .nav-2depth:deep(.btn-base) .icon-platform {
@apply fill-[#1F1F1F];
}
}
</style>