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

This commit is contained in:
“hyeonggkim”
2025-10-30 18:32:19 +09:00
41 changed files with 402 additions and 202 deletions

View File

@@ -1,217 +0,0 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type {
DownloadButtonType,
ButtonVariant,
} from '#layers/types/components/button'
type Platform = 'google_play' | 'app_store' | 'pc' | 'stove'
interface Props {
platform: Platform
type?: DownloadButtonType
variant?: ButtonVariant
backgroundColor?: string
textColor?: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
variant: 'filled',
disabled: false,
})
const runtimeConfig = useRuntimeConfig()
const { gameData } = useGameDataStore()
const { isProcessing, validateLauncher } = useCheckGameStart()
const PLATFORM_ICON_MAP: Record<Platform, string> = {
google_play: 'AtomsIconsLogoGoogle',
app_store: 'AtomsIconsLogoApple',
pc: 'AtomsIconsLogoWindow',
stove: 'AtomsIconsLogoStove',
} as const
const DUP_IMAGE_MAP: Record<Platform, string> = {
google_play: '/images/common/btn_logo-google.svg',
app_store: '/images/common/btn_logo-app.svg',
pc: '/images/common/btn_logo-pc.svg',
stove: '/images/common/btn_system_normal_stove_pc.svg',
} as const
const componentTag = computed(() => {
if (props.platform === 'stove') {
return 'a'
}
return 'button'
})
const isDuplication = computed(() => props.type === 'duplication')
const isSingle = computed(() => props.type === 'single')
const isCustom = computed(() => props.type === 'custom')
const platformIcon = computed(() => PLATFORM_ICON_MAP[props.platform])
const duplicationImage = computed(() =>
isDuplication.value ? DUP_IMAGE_MAP[props.platform] : ''
)
const inlineStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.backgroundColor) {
style.backgroundColor = props.backgroundColor
}
if (props.textColor) {
style.color = props.textColor
}
if (props.type === 'duplication') {
style.backgroundImage = `url(${duplicationImage.value})`
}
return style
})
const handleClick = () => {
if (props.platform === 'pc') {
validateLauncher()
return
}
if (props.platform === 'stove') {
const stoveClientDownloadUrl = runtimeConfig.public.stoveClientDownloadUrl
location.href = stoveClientDownloadUrl
return
}
const url = gameData?.market_json[props.platform]?.url || ''
window.open(url, '_blank')
}
</script>
<template>
<component
:is="componentTag"
v-bind="$attrs"
:class="[
'btn-base',
props.type,
{ 'no-text': isSingle && !$slots.default },
]"
:data-variant="props.variant"
:data-platform="props.platform"
:style="inlineStyle"
:disabled="disabled || isProcessing"
@click="handleClick"
>
<span class="btn-content">
<component
:is="platformIcon"
v-if="!isDuplication && !isCustom"
class="icon-platform"
/>
<span class="text">
<slot />
</span>
<span v-if="type === 'default'" class="icon-download">
<AtomsIconsDownloadLine />
</span>
</span>
</component>
<ClientOnly>
<Teleport to="#teleports">
<BlocksModalClient />
</Teleport>
</ClientOnly>
</template>
<style scoped>
.btn-base {
@apply overflow-hidden inline-block relative font-medium cursor-pointer
before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full before:border before:border-white/10
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:transition-opacity after:duration-300 after:ease-in-out after:opacity-0;
}
.btn-base:hover {
@apply after:opacity-20;
}
.btn-content {
@apply relative flex items-center z-[1] text-left;
}
.icon-platform {
@apply w-5 h-5 flex-shrink-0;
}
.btn-base[data-variant='filled'] {
@apply bg-[#383838] text-[#ffffff];
}
.btn-base[data-variant='outlined'] {
@apply bg-white text-[#1F1F1F]
before:border-black/10
after:hidden;
}
.btn-base[data-variant='outlined'][data-platform='app_store'] svg,
.btn-base[data-variant='outlined'][data-platform='pc'] svg,
.btn-base[data-variant='outlined'][data-platform='stove'] svg {
@apply fill-[#1F1F1F];
}
/* default */
.btn-base.default {
@apply w-[296px] py-3.5 px-5 rounded-[4px]
md:w-[356px] md:py-4 md:px-6
before:rounded-[4px] after:rounded-[4px];
}
.btn-base.default .text {
@apply pl-2 pr-4 line-clamp-2 text-[14px]
md:text-[16px];
}
.btn-base.default .icon-download {
@apply border-l border-white/10 ml-auto pl-4;
}
.btn-base.default[data-platform='stove'] {
@apply bg-[#FC4420];
}
.btn-base.default[data-variant='outlined'] .icon-download {
@apply border-black/10;
}
.btn-base.default[data-variant='outlined'] .icon-download svg {
@apply fill-[#1F1F1F];
}
/* duplication */
.btn-base.duplication {
@apply items-start bg-[16px_50%] bg-[length:auto_28px] bg-no-repeat rounded-[4px]
before:rounded-[4px] after:rounded-[4px]
pt-[22px] pl-[47px] pr-[22px] pb-[7px] text-[11px]
md:h-[64px] md:pt-[30px] md:pl-[64px] md:pr-[28px] md:pb-[11px] md:text-[12px] md:bg-[20px_50%] md:bg-[length:auto_40px];
}
.btn-base.duplication[data-platform='google_play'] {
@apply min-w-[148px] md:min-w-[194px];
}
.btn-base.duplication[data-platform='app_store'] {
@apply min-w-[132px] md:min-w-[174px];
}
.btn-base.duplication[data-platform='pc'] {
@apply min-w-[113px] md:min-w-[150px];
}
.btn-base.duplication[data-platform='stove'] {
@apply min-w-[113px] md:min-w-[150px];
}
.btn-base.duplication .text {
@apply line-clamp-1;
}
/* single */
.btn-base.single {
@apply justify-center items-center text-[14px]
h-[40px] px-3.5 rounded-[4px]
md:h-[48px] md:rounded-[8px]
before:rounded-[4px] md:before:rounded-[8px];
}
.btn-base.single.no-text {
@apply min-w-[40px] px-0 md:min-w-[48px];
}
.btn-base.single.no-text .icon-platform {
@apply mx-auto;
}
.btn-base.single .icon-platform {
@apply mr-1;
}
</style>

View File

@@ -72,7 +72,7 @@ const handleLinkClick = (title: string) => {
@apply absolute bottom-0 left-0 w-full pt-[14px] px-[18px] pb-[16px] flex flex-col justify-end border-t border-white/10 bg-black/40 shadow-[0_-10px_10px_0_rgba(0,0,0,0.25)] backdrop-blur-[25px] md:pt-[20px] text-left md:px-[26px] md:pb-[26px];
}
.card-title {
@apply text-[14px] leading-[20px] font-medium text-white md:text-[18px] md:leading-[26px];
@apply text-[14px] leading-[20px] font-medium text-white line-clamp-1 md:text-[18px] md:leading-[26px];
}
.card-description {
@apply mt-[6px] text-[12px] leading-[18px] text-white/50 md:mt-1 md:text-[14px] md:leading-[24px];

View File

@@ -22,15 +22,17 @@ const pinToParent = computed(() => {
</script>
<template>
<div :class="['utile-container', { 'is-fixed': pinToParent }]">
<AtomsButtonScrollTop v-if="props.isShowTopBtn" />
<AtomsButtonSns v-if="props.isShowSnsBtn" />
</div>
<ClientOnly>
<div :class="['utile-container', { 'is-fixed': pinToParent }]">
<AtomsButtonScrollTop v-if="props.isShowTopBtn" />
<AtomsButtonSns v-if="props.isShowSnsBtn" />
</div>
</ClientOnly>
</template>
<style scoped>
.utile-container {
@apply fixed flex flex-col
@apply fixed flex flex-col z-[100]
bottom-[12px] right-[12px] gap-2 md:bottom-[40px] md:right-[40px] md:gap-3;
}
.utile-container.is-fixed {

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
interface props {
isShowDimmed?: boolean
contentText?: string
confirmButtonText?: string
isOutsideClose?: boolean
modalName?: string
}
const props = withDefaults(defineProps<props>(), {
isShowDimmed: false,
isOutsideClose: false,
})
const emit = defineEmits(['confirmButtonEvent'])
const isOpen = defineModel<boolean>('isOpen', { default: false })
const setButtonEvent = (event?: () => void | void) => {
if (typeof event === 'function') {
return event()
}
isOpen.value = false
}
const handleOutsideClick = () => {
if (props.isOutsideClose) {
isOpen.value = false
}
}
</script>
<template>
<Transition name="fade">
<div
v-if="isOpen"
:class="['modal-wrap', { dimmed: props.isShowDimmed }, props.modalName]"
@click="handleOutsideClick"
>
<div class="modal-area" @click.stop>
<div class="modal-content">
<p
v-if="props.contentText"
v-dompurify-html="props.contentText"
class="content-text"
></p>
<slot></slot>
<div class="content-btns">
<AtomsButtonVariant
@click="setButtonEvent(() => emit('confirmButtonEvent'))"
>
{{ props.confirmButtonText || '확인' }}
</AtomsButtonVariant>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.modal-area {
@apply max-w-[312px] p-6 bg-white rounded-[20px];
}
</style>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
const { isShowCheckLauncher, isShowDownloadLauncher, downloadLauncher } =
useCheckGameStart()
</script>
<template>
<AtomsModalLayer
v-model:is-open="isShowCheckLauncher"
:is-show-dimmed="true"
:is-outside-close="false"
:modal-name="'launcher'"
area-class="max-w-[480px] pt-[56px] px-[24px] pb-[24px] rounded-[8px]"
close-class="absolute top-[16px] right-[24px]"
>
<span class="ico-loading"></span>
<!-- [TODO] i18n 적용 -->
<!-- <p class="text-check">{{ tm('Common_Message_Check_Client').txt }}</p> -->
<p class="text-check">pc 클라이언트 실행 ...</p>
<Transition name="fade">
<div v-if="isShowDownloadLauncher" class="client-area">
<!-- <p
v-dompurify-html="tm('Common_Message_Download_Client').txt"
class="text-info"
></p>
<button type="button" class="btn-download" @click="downloadLauncher">
{{ tm('Common_Message_Install').txt }}
</button>
<p
v-dompurify-html="tm('Common_Message_Download_Close').txt"
class="text-tip"
></p> -->
<p class="text-info">
PC 클라이언트가 실행되지 않나요?
<br />
다운로드 전이라면 다운로드 진행해주세요
</p>
<AtomsButtonVariant class="max-w-[300px]" @click="downloadLauncher">
다운로드
</AtomsButtonVariant>
<p
v-dompurify-html="
'*PC 클라이언트가 정상 실행되었다면 팝업을 닫아 주세요.'
"
class="text-tip"
></p>
</div>
</Transition>
</AtomsModalLayer>
</template>
<style scoped>
.ico-loading {
@apply block mx-auto mb-4 w-[80px] h-[80px] bg-[url('/images/common/stove_loading_light.png')] bg-contain bg-center bg-no-repeat;
}
.text-check {
@apply mb-6 text-center text-[20px] font-bold leading-[30px] tracking-[-0.6px] text-[#333333];
}
.client-area {
@apply pt-4 border-t border-[rgba(0,0,0,0.08)] text-center;
}
.text-info {
@apply mb-3 text-[14px] font-medium leading-[24px] tracking-[-0.42px] text-[#333333];
}
.text-tip {
@apply mt-4 text-[14px] leading-[20px] tracking-[-0.42px] text-[#999999];
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
interface props {
isShowDimmed?: boolean
contentText?: string
confirmButtonText?: string
cancelButtonText?: string
isOutsideClose?: boolean
modalName?: string
}
const props = withDefaults(defineProps<props>(), {
isShowDimmed: false,
isOutsideClose: false,
})
const emit = defineEmits(['cancelButtonEvent', 'confirmButtonEvent'])
const isOpen = defineModel<boolean>('isOpen', { default: false })
const setButtonEvent = (event?: () => void) => {
if (event) {
event()
}
isOpen.value = false
}
const handleOutsideClick = () => {
if (props.isOutsideClose) {
isOpen.value = false
}
}
</script>
<template>
<Transition name="fade">
<div
v-if="isOpen"
:class="['modal-wrap', { dimmed: props.isShowDimmed }, props.modalName]"
@click="handleOutsideClick"
>
<div class="modal-area" @click.stop>
<div class="modal-content">
<p
v-if="props.contentText"
v-dompurify-html="props.contentText"
class="content-text"
></p>
<slot></slot>
<div class="content-btns">
<AtomsButtonVariant
variant="outlined"
@click="setButtonEvent(() => emit('cancelButtonEvent'))"
>
{{ props.cancelButtonText || '취소' }}
</AtomsButtonVariant>
<AtomsButtonVariant
@click="setButtonEvent(() => emit('confirmButtonEvent'))"
>
{{ props.confirmButtonText || '확인' }}
</AtomsButtonVariant>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.modal-area {
@apply max-w-[312px] p-6 bg-white rounded-[20px];
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
interface props {
isShowDimmed?: boolean
isOutsideClose?: boolean
modalName?: string
areaClass?: string
closeClass?: string
}
const props = withDefaults(defineProps<props>(), {
isShowDimmed: false,
isOutsideClose: false,
})
const isOpen = defineModel<boolean>('isOpen', { default: false })
const handleCloseModal = () => {
isOpen.value = false
}
const handleOutsideClick = () => {
if (props.isOutsideClose) {
handleCloseModal()
}
}
</script>
<template>
<Transition name="fade">
<div
v-if="isOpen"
:class="['modal-wrap', { dimmed: props.isShowDimmed }, props.modalName]"
@click="handleOutsideClick"
>
<div :class="['modal-area', props.areaClass]" @click.stop>
<div class="modal-content">
<slot></slot>
</div>
<button
type="button"
:class="['modal-close', props.closeClass]"
@click="handleCloseModal"
>
<span class="sr-only">close</span>
<AtomsIconsCloseLine size="24" color="#333333" />
</button>
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { getYouTubeEmbedUrl } from '@/layers/utils/youtubeUtil'
interface Props {
youtubeUrl: string
isOutsideClose?: boolean
modalName?: string
}
const props = withDefaults(defineProps<Props>(), {
youtubeUrl: '',
isOutsideClose: false,
})
const emit = defineEmits(['closeButtonEvent'])
const isOpen = defineModel<boolean>('isOpen', { default: false })
const embedUrl = computed(() => {
return getYouTubeEmbedUrl(props.youtubeUrl)
})
const handleCloseModal = () => {
isOpen.value = false
emit('closeButtonEvent')
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
handleCloseModal()
}
}
const handleOutsideClick = () => {
if (props.isOutsideClose) {
handleCloseModal()
}
}
// 키보드 이벤트 리스너 등록/해제
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<Transition name="fade">
<div
v-if="isOpen"
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-75 z-[800]"
:class="props.modalName"
@click="handleOutsideClick"
>
<div
class="relative mx-4 my-4"
style="
width: min(896px, 90vw, calc((90vh - 2rem) * 16 / 9));
aspect-ratio: 16/9;
"
@click.stop
>
<!-- 헤더 -->
<div class="flex justify-end mb-3 md:mb-4">
<button type="button" @click="handleCloseModal">
<AtomsIconsCloseLine />
</button>
</div>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full h-full">
<iframe
v-if="embedUrl"
:src="embedUrl"
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>
</div>
</Transition>
</template>

View File

@@ -6,6 +6,7 @@ import type { PageDataResourceGroups } from '#layers/types/api/pageData'
interface Props {
type?: ResponsiveOptions['type']
slideItemLength?: number
gap?: number
autoplay?: boolean
perPage?: number
drag?: boolean
@@ -16,6 +17,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
gap: 0,
autoplay: false,
perPage: 1,
drag: true,
@@ -39,6 +41,7 @@ const options = computed((): ResponsiveOptions => {
type: slideType.value,
autoWidth: true,
autoHeight: true,
gap: props.gap,
perPage: props.perPage,
speed: 500,
updateOnMove: true,