fix. 컴포넌트 폴더 구조 변경

This commit is contained in:
clkim
2025-09-16 15:10:33 +09:00
parent 0b413bb197
commit 62e06b30f4
25 changed files with 58 additions and 37 deletions

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
interface Props {
to: string
target?: string
class?: string
}
const props = withDefaults(defineProps<Props>(), {
target: '',
class: '',
})
</script>
<template>
<a
v-if="props.target === '_blank'"
v-bind="$attrs"
:href="props.to"
:target="props.target"
:class="props.class"
>
<slot />
</a>
<AtomsLocaleLink v-else v-bind="$attrs" :to="props.to" :class="props.class">
<slot />
</AtomsLocaleLink>
</template>

View File

@@ -0,0 +1,52 @@
<template>
<div class="bg-white">
<select
v-model="selectedLocale"
class="text-black px-2 py-1 rounded-md"
@change="switchLanguage"
>
<option
v-for="localeOption in availableLanguages"
:key="localeOption"
:value="localeOption"
>
{{ localeOption }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
const gameDataStore = useGameDataStore()
// 사용 가능한 언어 목록
const availableLanguages = computed(() => {
return gameDataStore.gameData?.lang_codes || ['ko']
})
const { locale } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const router = useRouter()
const selectedLocale = ref(locale.value)
// 언어 변경 함수
const switchLanguage = async () => {
console.log(
'🚀 ~ switchLanguage ~ selectedLocale.value:',
selectedLocale.value
)
if (selectedLocale.value) {
// URL 경로를 통해 언어 변경
const path = switchLocalePath(selectedLocale.value)
if (path) {
await router.push(path)
}
}
}
// locale이 변경될 때 selectedLocale도 동기화
watch(locale, newLocale => {
selectedLocale.value = newLocale
})
</script>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import { useTemplateRegistry } from '#layers/composables/useTemplateRegistry'
import type { PageDataTemplate } from '#layers/types/api/pageData'
const props = defineProps<{ templates: PageDataTemplate[] }>()
const registry = useTemplateRegistry() as Record<string, { component: any }>
const isShowTemplate = (template: PageDataTemplate) => {
return template?.components && Object.keys(template.components).length > 0
}
</script>
<template>
<main>
<template
v-for="(template, index) in props.templates"
:key="template.template_code ?? index"
>
<component
:is="registry[template.template_code]?.component"
v-if="isShowTemplate(template)"
:components="template.components"
/>
</template>
</main>
</template>

View File

@@ -0,0 +1,61 @@
<template>
<div id="header-stove" class="relative z-[5]" />
</template>
<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
const runtimeConfig = useRuntimeConfig()
const { locale, availableLocales } = useI18n()
const { gameData } = useGameDataStore()
const stoveInflowPath = runtimeConfig.public.stoveInflowPath
const stoveGameNo = runtimeConfig.public.stoveGameNo
const gnbData = gameData?.stove_gnb
const languageCodes = computed(() => {
if (Array.isArray(availableLocales)) {
return availableLocales.map(
(localeCode: any) => localeCode.code || localeCode
)
}
return [locale]
})
function loadGnb(locale: string) {
locale = locale.toLowerCase()
const gnbOption = {
wrapper: '#header-stove',
isResponsive: true,
skin: gnbData?.skin_type || 'gnb-dark-mini',
widget: {
gameListAndService: false,
languageSelect: false,
notification: false,
stoveDownload: false,
},
global: {
userGds: true,
defaultSelectedLanguage: locale || 'en',
languageCoverages: languageCodes.value,
},
loginMethod: {
params: {
inflow_path: stoveInflowPath,
game_no: stoveGameNo,
show_play_button: gnbData?.stove_install_button_visible || 'Y',
},
redirectCurrentPage: true,
windowTitle: undefined,
},
}
const cpHeader = new (window as any).cp.Header(gnbOption)
cpHeader.render()
}
onMounted(() => {
loadGnb(locale.value)
})
</script>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
interface Props {
tag: string
text: string
imageSrc?: any
imageClass?: string
}
const props = defineProps<Props>()
</script>
<template>
<component :is="tag" v-bind="$attrs">
<template v-if="imageSrc && 'mobileSrc' in imageSrc">
<!-- 모바일 이미지 (sm 미만) -->
<img
v-if="imageSrc.mobileSrc"
:src="imageSrc.mobileSrc"
:alt="text"
:class="`${props.imageClass} sm:hidden`"
/>
<!-- PC 이미지 (sm 이상) -->
<img
v-if="imageSrc.pcSrc"
:src="imageSrc.pcSrc"
:alt="text"
:class="`${props.imageClass} hidden sm:block`"
/>
</template>
<span v-else v-html="text?.replace(/\n/g, '<br/>') || ''" />
</component>
</template>

View File

@@ -1,257 +0,0 @@
<template>
<footer id="footer" ref="footerRef" class="bg-black">
<div
class="inner relative max-w-7xl mx-auto px-10 py-8 text-[12px] text-gray-400 md:px-4 md:py-7 md:text-[12px]"
>
<div class="menu-area">
<ul class="flex items-center flex-wrap md:gap-1.5">
<li
v-for="(footerMenuItem, index) in footerLinks"
:key="index"
class="text-sm md:text-[11px] md:tracking-[-0.5px] relative flex items-center"
:class="{
'before:content-[\'\'] before:inline-block before:bg-gray-500 before:h-2 before:w-px before:mx-1.5 before:mt-1 before:align-top md:before:mt-1':
index > 0,
'md:before:hidden': index === 4,
}"
>
<NuxtLink
:to="footerMenuItem.link"
:target="footerMenuItem.target"
:class="[
footerMenuItem.active === 'y' && 'text-[#e04600]',
index === 2 && 'text-[#e04600]',
'hover:text-gray-600 transition-colors',
]"
>
{{ footerMenuItem.title }}
</NuxtLink>
</li>
</ul>
</div>
<div class="flex mt-2.5 md:flex-col md:mt-1.5">
<img
:src="footerData.game_rating_image_url"
alt="게임등급"
class="w-10 h-14 md:w-10 md:h-12 md:order-1"
/>
<dl
class="grid grid-cols-[110px_auto_110px_auto] w-full max-w-[490px] ml-5 border-t border-l border-gray-600 tracking-tight md:grid-cols-[66px_auto_84px_auto] md:max-w-[358px] md:m-0 md:mb-2.5"
>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
게임명
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.title }}
</dd>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
상호
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.company_name }}
</dd>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
이용등급
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.reg_no }}
</dd>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
등급분류번호
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.rating_grade }}
</dd>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
제작년월일
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.prod_date }}
</dd>
<dt
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
>
신고(등록)번호
</dt>
<dd
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
>
{{ footerData.game_rating_info.rating_class_no }}
</dd>
</dl>
</div>
<div class="address-area mt-6">
<address class="not-italic text-gray-500">
<div class="row my-1.5">
<span
v-dompurify-html="footerData.footer_info"
class="[&_a]:cursor-pointer [&_a]:text-blue-500 [&_a]:underline"
></span>
</div>
</address>
</div>
<div class="copyright-area mt-5 text-gray-500 md:mt-4">
<span>&copy; Smilegate. All rights reserved</span>
</div>
<div class="logo-area flex mt-3 md:mt-2.5">
<a
:href="
locale === 'ja'
? 'https://www.smilegate.com/jp'
: 'https://www.smilegate.com/en'
"
target="_blank"
class="smilegate"
>
<img
:src="footerData.dev_ci_url"
alt="스마일게이트 로고"
class="w-auto h-auto"
/>
</a>
<a
href="https://www.nx3games.com"
target="_blank"
class="nx3 ml-2.5 md:ml-4"
>
<img
:src="footerData.dev_ci_url2"
alt="NX3 로고"
class="w-auto h-auto"
/>
</a>
</div>
<div
class="language-area absolute bottom-7 right-10 text-white md:bottom-5.5 md:right-4"
>
<MoleculesLanguageSwitcher />
<!-- <SelectLanguage /> -->
<!-- <AtomsLanguageSwitcher /> -->
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const { locale } = useI18n()
interface FooterMenuType {
id: string
title: string
link: string
target: string
active: string
highlight?: string
}
const footerLinks = ref<FooterMenuType[]>([
{
id: 'company',
title: '회사소개',
link: '#',
target: '_blank',
active: 'n',
},
{
id: 'terms',
title: '이용약관',
link: 'https://common.game.onstove.com/terms/index?gameType=SG&termsType=1&langCode=@m{Terms_Lang_Code}',
target: '_blank',
active: 'n',
},
{
id: 'privacy',
title: '개인정보처리방침',
link: 'https://clause.onstove.com/stove/terms?category=privacy',
target: '_blank',
active: 'y',
},
{
id: 'operation',
title: '운영정책',
link: 'https://common.game.onstove.com/terms/index?gameType=CZN&termsType=3&langCode=@m{Terms_Lang_Code}',
target: '_blank',
active: 'n',
},
{
id: 'fund',
title: '청소년보호정책',
link: 'https://common.game.onstove.com/terms/index?gameType=CZN&termsType=6&langCode=ja',
target: '_blank',
active: 'n',
},
{
id: 'customerService',
title: '게임 이용 등급',
link: 'https://cs.onstove.com/@m{Terms_Lang_Code}/service/STOVE_CHAOSZERO',
target: '_blank',
active: 'n',
},
] as FooterMenuType[])
const footerData = ref({
dev_ci_url:
'https://static-pubcomm.gate8.com/local/template/l9/common/logo_smilegate.png',
dev_ci_url2:
'https://static-pubcomm.gate8.com/local/template/l9/common/logo_nx3.png',
game_rating_image_url:
'https://static-pubcomm.gate8.com/local/template/l9/common/grades_age/Type15.svg',
use_dev_ci_url: true,
fund_display_yn: true,
use_game_rating: true,
fund_display_url: 'https://testgame.com/law/fund-ko',
game_rating_info: {
title: '테스트 게임',
reg_no: 'R-2024-7890',
prod_date: '2024-05-01',
rating_type: '15',
company_name: '테스트엔터테인먼트',
content_info: '1,2,3,',
rating_grade: '15세 이용가',
rating_class_no: '2024-123456',
},
footer_info:
"(주)스마일게이트홀딩스 메가포트지점 대표: 성준호<br>주소: 경기도 성남시 분당구 분당로 55, 7층 (서현동 분당 퍼스트타워)<br>통신판매업 신고번호: 제2023-성남분당A-0145호<br>사업자등록번호: 813-85-02492<br>E-mail: <a href='mailto:help@smilegate.com'>help@smilegate.com</a><br>TEL: 1670-0399",
})
</script>
<style scoped>
/* 태국어 폰트 크기 조정 */
@media (max-width: 411px) {
:global(.lang-th) .menu-area li {
font-size: 10px;
}
}
@media (max-width: 321px) {
:global(.lang-th) .menu-area li {
font-size: 9px;
}
}
</style>

View File

@@ -1,96 +0,0 @@
<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type { GameDataValue, GameDataGnb } from '#layers/types/api/gameData'
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const gnbList = gameData?.gnb?.menus as GameDataGnb['menus']
</script>
<template>
<header
class="bg-theme-foreground text-theme-foreground-reversal relative z-50"
>
<MoleculesStoveGnb />
<div
data-name="header-game"
class="px-[40px] h-16 flex items-center whitespace-nowrap"
>
<!-- 로고 -->
<div data-name="header-logo" class="mr-[40px]">
<AtomsLocaleLink to="/brand">
<img
:src="gameData?.gnb?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</AtomsLocaleLink>
</div>
<!-- 메인 네비게이션 -->
<nav data-name="header-nav" class="flex items-center space-x-[32px]">
<template v-if="gnbList">
<div
v-for="(gnbItem, key) in gnbList"
:key="key"
class="relative group"
>
<!-- Link 컴포넌트 사용 -->
<MoleculesHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
class="relative flex items-center h-[64px]"
>
{{ gnbItem.menu_name }}
<AtomsIconsArrowDown v-if="gnbItem.children" class="ml-1" />
<span
class="absolute bottom-0 left-0 w-full h-2 border-b-2 border-transparent transition-border-color group-hover:border-theme-foreground-reversal group-active:border-theme-foreground-reversal-10"
/>
</MoleculesHybridLink>
<div
v-if="gnbItem.children"
class="absolute top-full left-[-28px] min-w-[190px] pt-[4px] pointer-events-none group-hover:pointer-events-auto"
>
<ul
class="bg-theme-foreground-10 rounded-[20px] shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 p-3"
>
<li v-for="child in gnbItem.children" :key="child.menu_name">
<!-- Link 컴포넌트 사용 -->
<MoleculesHybridLink
:to="child.url_path"
:target="child.link_target"
class="flex items-center px-4 py-[9px] rounded-[12px] transition-background hover:bg-theme-foreground-reversal-40 active:bg-theme-foreground-reversal-70"
>
{{ child.menu_name }}
<AtomsIconsLinkOut
v-if="child.link_target === '_blank'"
class="ml-1"
/>
</MoleculesHybridLink>
</li>
</ul>
</div>
</div>
</template>
<!-- 구분선 -->
<div class="w-px h-4 bg-theme-foreground-reversal-30" />
<!-- 이벤트 -->
<a href="#" class="flex items-center space-x-[3px] text-gradient-pink">
<AtomsIconsStar />
<span>이벤트</span>
<AtomsIconsStar />
</a>
</nav>
<!-- 오른쪽 영역 -->
<div data-name="header-right" class="ml-auto">
<div class="relative group">
<AtomsButton size="small">게임 시작</AtomsButton>
</div>
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useLoadingStore } from '#layers/stores/useLoadingStore'
const loadingStore = useLoadingStore()
const { fullLoading } = storeToRefs(loadingStore)
</script>
<template>
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="fullLoading"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useLoadingStore } from '#layers/stores/useLoadingStore'
const loadingStore = useLoadingStore()
const localLoadings = computed(() => Object.entries(loadingStore.localLoadings))
const canTeleport = (localId: string) => {
if (!import.meta.client) {
return false
}
return !!document.getElementById(localId)
}
</script>
<template>
<template v-for="[localId, loadingInfo] in localLoadings" :key="localId">
<Teleport v-if="canTeleport(localId)" :to="`#${localId}`">
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="loadingInfo.active"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
</template>

View File

@@ -0,0 +1,134 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click="handleBackdropClick"
>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="isOpen" class="relative w-full max-w-4xl mx-4" @click.stop>
<!-- 헤더 -->
<div class="flex justify-end">
<button
class="p-1 text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full" :style="{ paddingBottom: '56.25%' }">
<iframe
v-if="youtubeId"
:src="`https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0`"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="YouTube video player"
/>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
interface Props {
isOpen: boolean
youtubeId: string
title?: string
description?: string
closeOnBackdrop?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'update:isOpen', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
title: '',
description: '',
closeOnBackdrop: true,
})
const emit = defineEmits<Emits>()
// ESC 키로 모달 닫기
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) {
closeModal()
}
}
// 배경 클릭으로 모달 닫기
const handleBackdropClick = () => {
if (props.closeOnBackdrop) {
closeModal()
}
}
// 모달 닫기 함수
const closeModal = () => {
emit('close')
emit('update:isOpen', false)
}
// 키보드 이벤트 리스너 등록/해제
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 모달이 열릴 때 body 스크롤 방지
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
)
// 컴포넌트 언마운트 시 body 스크롤 복원
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>