fix: [PWT-169] 사전 등록 > 기기 체크 없이 스토브 앱 다운로드 버튼 노출되는 현상 문의

This commit is contained in:
clkim
2025-12-08 18:37:02 +09:00
parent 379c1e1b6a
commit 418adfec17
7 changed files with 127 additions and 119 deletions

View File

@@ -27,10 +27,10 @@ const getEventNavigation = async (): Promise<Record<
const response = (await commonFetch('GET', apiUrl, {
query: queryParams,
})) as EventNavigationResponse | null
if (response?.code === 0 && 'value' in response) {
return response.value
}
return null
}

View File

@@ -22,11 +22,13 @@ const { tm } = useI18n()
const { width } = useWindowSize()
const device = useDevice()
const gameDataStore = useGameDataStore()
const pageDataStore = usePageDataStore()
const scrollStore = useScrollStore()
const breakpoints = useResponsiveBreakpoints()
const modalStore = useModalStore()
const { gameData } = storeToRefs(gameDataStore)
const { pageLayoutType } = storeToRefs(pageDataStore)
const { isPassedStoveGnb } = storeToRefs(scrollStore)
const navAreaRef = ref<HTMLElement | null>(null)
@@ -48,45 +50,29 @@ const hasGnbMenus = computed(() => {
return Object.keys(menus).length > 0
})
const currentPath = computed(() => formatPathWithoutLocale(route.path))
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[]
getSupportedPlatforms(
gameData.value?.os_type,
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)
if (!child?.url_path || child.click_action_type === 2) return false
return formatPathWithoutLocale(child.url_path) === currentPath.value
})
}
/** 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 (!import.meta.client) return
@@ -158,7 +144,9 @@ const handleMenuOpen = () => {
scrollStore.controlScrollLock(true)
}
const handleMenuClose = () => {
const handleMenuClose = (isPassing: boolean = false) => {
if (isPassing) return
isMenuOpen.value = false
scrollStore.controlScrollLock(false)
}
@@ -198,7 +186,7 @@ const showNotSupportedOSAlert = () => {
}
const handleStartClick = () => {
if (breakpoints.value.isDesktop) return
if (device.isDesktop) return
const target = device.isAndroid
? 'google_play'
@@ -259,7 +247,7 @@ onMounted(() => {
</button>
<div
:class="['nav-wrap', { 'is-open': isMenuOpen }]"
@click="handleMenuClose"
@click="handleMenuClose()"
>
<div ref="navAreaRef" class="nav-area" @click.stop>
<div class="nav-logo">
@@ -277,7 +265,7 @@ onMounted(() => {
<div
v-for="(gnbItem, key) in gnbData?.menus"
:key="key"
class="nav-item"
class="nav-item group"
:class="{
'is-hidden':
overflowNam > 0 &&
@@ -285,19 +273,21 @@ onMounted(() => {
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<AtomsLocaleLink
:to="isNotClickable(gnbItem) ? '#' : gnbItem.url_path"
:target="gnbItem.link_target"
<component
:is="isNotClickable(gnbItem) ? 'span' : 'AtomsLocaleLink'"
:to="gnbItem?.url_path"
:target="gnbItem?.link_target"
:class="[
'nav-1depth',
{ 'has-link': !isNotClickable(gnbItem) },
{ active: isNavItemActive(gnbItem) },
{
'router-link-active': hasActiveChild(gnbItem.children),
},
]"
@click="handleMenuClose"
@click="handleMenuClose(isNotClickable(gnbItem))"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="gnbItem.link_target === '_blank'"
v-if="gnbItem?.link_target === '_blank'"
/>
<AtomsIconsArrowDownFill
v-if="has2depthButton(gnbItem)"
@@ -307,7 +297,7 @@ onMounted(() => {
v-if="!has2depthButton(gnbItem)"
class="ml-auto md:hidden"
/>
</AtomsLocaleLink>
</component>
<Transition name="fade">
<div v-if="has2depthButton(gnbItem)" class="nav-2depth">
<ul>
@@ -315,23 +305,27 @@ onMounted(() => {
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<AtomsLocaleLink
<component
:is="
isNotClickable(child) ? 'span' : 'AtomsLocaleLink'
"
:to="child.url_path"
:target="child.link_target"
@click="handleMenuClose"
class="item-link"
@click="handleMenuClose(isNotClickable(child))"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
v-if="child?.link_target === '_blank'"
/>
</AtomsLocaleLink>
</component>
</li>
</ul>
</div>
</Transition>
</div>
</div>
<div v-if="overflowNam > 0" class="more">
<div v-if="overflowNam > 0" class="more group">
<button class="btn-more">
<AtomsIconsOptionHorizontalFill class="mx-auto" />
<span class="sr-only">more</span>
@@ -347,29 +341,46 @@ onMounted(() => {
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<AtomsLocaleLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
@click="handleMenuClose"
<component
:is="
isNotClickable(gnbItem) ? 'span' : 'AtomsLocaleLink'
"
:to="gnbItem?.url_path"
:target="gnbItem?.link_target"
:class="[
'nav-1depth',
'item-link',
{
'router-link-active': hasActiveChild(
gnbItem.children
),
},
]"
@click="handleMenuClose(isNotClickable(gnbItem))"
>
<span>{{ gnbItem.menu_name }}</span>
</AtomsLocaleLink>
<div v-if="gnbItem.children">
</component>
<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"
<component
:is="
isNotClickable(child)
? 'span'
: 'AtomsLocaleLink'
"
:to="child?.url_path"
:target="child?.link_target"
class="item-link"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
v-if="child?.link_target === '_blank'"
/>
</AtomsLocaleLink>
</component>
</li>
</ul>
</div>
@@ -388,13 +399,20 @@ onMounted(() => {
? '_self'
: '_blank'
"
class="nav-1depth text-gradient-pink"
:class="[
'nav-1depth',
{ 'router-link-active': pageLayoutType === 'promotion' },
]"
@click="handleMenuClose"
>
<AtomsIconsStarFill />
<span>{{ tm('Gnb_Event') }}</span>
<AtomsIconsStarFill />
<AtomsIconsArrowRightLine class="ml-auto md:hidden" />
<span
class="flex items-center gap-1 flex-1 text-gradient-pink"
>
<AtomsIconsStarFill />
<span>{{ tm('Gnb_Event') }}</span>
<AtomsIconsStarFill />
<AtomsIconsArrowRightLine class="ml-auto md:hidden" />
</span>
</AtomsLocaleLink>
</div>
</div>
@@ -405,9 +423,7 @@ onMounted(() => {
<template v-if="gnb1depthButtonData">
<component
:is="
breakpoints.isDesktop
? 'BlocksButtonLauncher'
: 'AtomsButton'
device.isDesktop ? 'BlocksButtonLauncher' : 'AtomsButton'
"
type="custom"
platform="pc"
@@ -436,7 +452,7 @@ onMounted(() => {
</template>
</div>
</ClientOnly>
<button class="btn-close" @click="handleMenuClose">
<button class="btn-close" @click="handleMenuClose()">
<AtomsIconsCloseLine
size="24"
color="var(--foreground-reversal)"
@@ -477,6 +493,24 @@ onMounted(() => {
@apply top-[11px] left-[12px];
}
a {
@apply transition-[background] hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10;
}
a.nav-1depth:not(.item-link) {
@apply md:hover:bg-transparent md:active:bg-transparent;
}
.nav-1depth.router-link-active {
@apply bg-theme-foreground-reversal-8 md:bg-transparent;
}
.more-list .nav-1depth.router-link-active {
@apply bg-theme-foreground-reversal-8;
}
.nav-item:hover .nav-1depth::after,
.nav-1depth.router-link-active::after {
@apply opacity-100 md:transition-opacity;
}
.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;
}
@@ -516,13 +550,6 @@ onMounted(() => {
.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;
@@ -530,23 +557,16 @@ onMounted(() => {
.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;
@apply text-[15px] group-hover:block 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;
.nav-2depth .item-link {
@apply flex items-center gap-1 py-[9px] px-5 rounded-[12px]
md:py-[11px] md:px-4;
}
.official {
@@ -562,25 +582,24 @@ onMounted(() => {
.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;
@apply hidden absolute top-[64px] left-[-20px] pt-1 group-hover:block;
}
.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 .item-link {
@apply flex items-center gap-1 py-[10px] px-4 rounded-[12px];
}
.more-list li a {
.more-list li .item-link {
@apply px-6;
}
.more-list .nav-1depth {
@apply after:hidden;
}
.official ~ .event {
@apply md:ml-[64px]

View File

@@ -42,7 +42,6 @@ const currentStep = ref<1 | 2>(1)
const isSubmitting = ref(false)
const isCheckedMarketing = ref(false)
const isExpandedMarketing = ref(false)
const isValidated = ref(false) // 검증 완료 여부 (중복 검증 방지)
const canSubmit = computed(() => isCheckedMarketing.value)
const errorMessages = computed<Record<number, string>>(() => ({
@@ -168,7 +167,6 @@ const handleOpenPreregist = async (): Promise<void> => {
})
}
isValidated.value = true // 검증 완료 플래그
isModalOpen.value = true
currentStep.value = 1
}
@@ -186,11 +184,8 @@ const handleSubmit = async (): Promise<void> => {
return
}
// 이미 검증을 통과한 경우 재검증 스킵
if (!isValidated.value) {
const isValid = await checkValidation()
if (!isValid) return
}
const isValid = await checkValidation()
if (!isValid) return
isSubmitting.value = true
try {
@@ -199,7 +194,7 @@ const handleSubmit = async (): Promise<void> => {
event_code: props.preregistCode,
lang_code: locale.value,
terms_type: 3,
device_type: device.isMobile ? 'mobile' : 'pc',
device_type: device.isDesktop ? 'pc' : 'mobile',
country_code: countryCode.value || 'KR',
necessary_consent1: 'Y',
necessary_consent2: 'Y',
@@ -234,7 +229,6 @@ const handleCloseModal = (): void => {
isCheckedMarketing.value = false
isExpandedMarketing.value = false
isSubmitting.value = false
isValidated.value = false // 검증 플래그도 초기화
}
defineExpose({
@@ -385,6 +379,7 @@ defineExpose({
</p>
<div class="flex items-center gap-3">
<div
v-if="device.isDesktop"
class="flex size-[108px] p-4 shrink-0 items-center justify-center rounded-lg bg-white/[0.04] backdrop-blur-[15px] md:size-[124px] md:p-4.5"
>
<AtomsImg
@@ -396,6 +391,7 @@ defineExpose({
</div>
<div class="flex flex-1 flex-col gap-3">
<a
v-if="device.isDesktop ? true : device.isAndroid"
href="https://play.google.com/store/search?q=stove&c=apps"
target="_blank"
class="flex h-12 w-full items-center justify-center gap-1.5 rounded-lg bg-white/[0.04] px-8 pl-8 pr-10 text-sm font-medium leading-5 tracking-[-0.42px] text-white no-underline backdrop-blur-[15px] transition-colors duration-200 hover:bg-white/[0.08] md:h-14 md:gap-2 md:text-base md:leading-6 md:tracking-[-0.48px]"
@@ -404,6 +400,7 @@ defineExpose({
<span>Google Play</span>
</a>
<a
v-if="device.isDesktop ? true : device.isApple"
href="https://apps.apple.com/app/stove-app-stove-app/id1342134971"
target="_blank"
class="flex h-12 w-full items-center justify-center gap-1.5 rounded-lg bg-white/[0.04] px-8 pl-8 pr-10 text-sm font-medium leading-5 tracking-[-0.42px] text-white no-underline backdrop-blur-[15px] transition-colors duration-200 hover:bg-white/[0.08] md:h-14 md:gap-2 md:text-base md:leading-6 md:tracking-[-0.48px]"

View File

@@ -2,17 +2,23 @@ import type { PageDataValue } from '#layers/types/api/pageData'
export const usePageDataStore = defineStore('pageData', () => {
const pageData = ref<PageDataValue | null>(null)
const pageLayoutType = ref<'default' | 'promotion' | null>(null)
const setPageData = (response: PageDataValue) => {
clearPageData()
pageData.value = response
pageLayoutType.value = getLayoutType(pageData.value)
}
const clearPageData = () => {
pageData.value = null
pageLayoutType.value = null
}
return {
pageData,
pageLayoutType,
setPageData,
clearPageData,
}

View File

@@ -14,7 +14,6 @@ const props = defineProps<Props>()
// Configuration
const runtimeConfig = useRuntimeConfig()
const breakpoints = useResponsiveBreakpoints()
const device = useDevice()
const dataResourcesUrl = runtimeConfig.public.dataResourcesUrl as string
@@ -200,7 +199,7 @@ const splideOptions = computed(() => {
})
const isRunButtonVisible = (marketType?: Platform): boolean => {
if (breakpoints.value?.isDesktop) return true
if (device.isDesktop) return true
switch (marketType) {
case 'google_play':
@@ -291,7 +290,7 @@ const handlePreregistClick = () => {
{{ tm('Preregist_Btn_Preegist') }}
</BlocksButtonLauncher>
<template
v-for="platform in getSupportedPlatforms(gameData?.os_type)"
v-for="platform in getSupportedPlatforms(gameData?.os_type, '2')"
:key="`preregist-${platform}`"
>
<BlocksButtonLauncher

View File

@@ -3,7 +3,7 @@
* @description gameData, pageData 처리에 필요한 유틸리티 함수를 제공합니다.
*/
import type { PlatformType } from '#layers/types/api/gameData'
import type { OsType, PlatformType } from '#layers/types/api/gameData'
import type {
PageDataValue,
PageDataResourceContainer,
@@ -23,12 +23,10 @@ const OS_TYPE_MAP: Record<string, string[]> = {
* OS 타입에 따라 가능한 플랫폼 목록을 반환합니다.
*/
export const getSupportedPlatforms = (
osType: string | number,
options?: { platformType?: PlatformType }
osType: OsType,
platformType: PlatformType
): string[] => {
const type = String(osType)
const platformType = String(options?.platformType ?? '0')
const storePlatforms = OS_TYPE_MAP[type] ?? []
const storePlatforms = OS_TYPE_MAP[osType] ?? []
switch (platformType) {
case '1': // PC 전용
@@ -37,11 +35,9 @@ export const getSupportedPlatforms = (
case '2': // 모바일 스토어 전용
return storePlatforms
default:
case '3': // PC + 모바일 스토어 모두
return ['pc', ...storePlatforms]
default: // 기본: OS_TYPE_MAP
return storePlatforms
}
}

View File

@@ -45,15 +45,6 @@ export const formatPathWithoutLocale = (path: string): string => {
return path.replace(/^\/[a-z]{2}(?=\/|$)/, '') || '/'
}
/**
* URL이 내부 링크인지 확인합니다.
* @param url 확인할 URL
* @returns 내부 링크 여부
*/
export const isInternalUrl = (url?: string): boolean => {
return !!url && !url.startsWith('http')
}
/**
* 리소스 경로를 완전한 호스트 URL로 변환합니다.
* @param path 리소스 경로