feat: 고정형페이지-다운로드

This commit is contained in:
최만억 (Jo)
2025-10-31 08:30:58 +00:00
committed by 김채린
parent 83124d56eb
commit 8a281033b4
20 changed files with 826 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
title: string
resourcesData: PageDataResourceGroup
}>()
const { getCurrentSrc } = useResponsiveSrc()
const resPath = computed(() => props.resourcesData?.res_path)
const imageSrc = computed(() => {
return getCurrentSrc(resPath.value)
})
</script>
<template>
<div
class="flex justify-center items-center w-full h-[120px] bg-center px-[20px] bg-no-repeat bg-cover sm:px-[40px] md:h-[180px]"
:style="{ backgroundImage: `url(${imageSrc})` }"
>
<h2
class="flex justify-center items-center w-full max-w-full md:justify-start md:max-w-[1300px]"
>
<span
class="flex justify-center items-center text-white text-[20px] font-bold tracking-[-0.6px] leading-[30px] md:text-[32px] md:leading-[44px] md:tracking-[-0.96px]"
>
{{ props.title }}
</span>
</h2>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
interface Props {
title: string
description?: string
link?: string
target?: '_self' | '_blank'
}
const props = withDefaults(defineProps<Props>(), {
target: '_blank',
})
const componentTag = computed((): string => {
switch (props.target) {
case '_self':
return 'AtomsLocaleLink'
case '_blank':
return 'a'
default:
return 'a'
}
})
const componentProps = computed(() => {
if (props.target === '_self') {
return {
to: props.link,
}
}
if (props.target === '_blank') {
return {
href: props.link,
target: props.target,
rel: 'noopener noreferrer',
}
}
})
</script>
<template>
<div class="flex flex-wrap items-end justify-between">
<h3
class="text-[#1F1F1F] text-[18px] font-bold leading-[26px] tracking-[-0.54px] md:text-[24px] md:leading-[34px] md:tracking-[0.72px]"
>
<span>{{ props.title }}</span>
</h3>
<div class="flex items-center justify-between">
<slot />
<p
v-if="props.description && !props.link"
class="text-[#666666] text-[13px] font-[400] leading-[22px] tracking-[-0.325px] md:text-[14px] md:leading-[24px] md:tracking-[-0.421px]"
>
{{ props.description }}
</p>
<component
:is="componentTag"
v-else-if="props.description && props.link"
v-bind="componentProps"
class="relative flex items-center justify-center gap-[4px] w-auto h-auto text-[#3C75FF] text-[14px] font-[500] leading-[20px] tracking-[-0.42px] md:text-[16px] md:leading-[24px] md:tracking-[-0.48px] before:content-[''] before:absolute before:z-[2] before:top-0 before:left-0 before:w-full before:h-full before:bg-[#FFFFFF] before:transition-opacity before:duration-300 before:ease-in-out before:opacity-0 hover:before:opacity-20"
>
<span>{{ props.description }}</span>
<AtomsIconsWebLinkLine
v-if="props.target === '_blank'"
:size="20"
color="#3C75FF"
class="icon"
/>
</component>
</div>
</div>
</template>

View File

@@ -0,0 +1,189 @@
import { useTokenValidation } from '#layers/composables/useTokenValidation'
import { csrGoStoveLogin } from '#layers/utils/stoveUtil'
interface ReqCheckSpec {
schemeFormat: string
setupUrl: string
gameNo: string
locale: string
}
export const useCheckPCSpec = (tm: Function) => {
const { isTokenValid, validateToken } = useTokenValidation()
// Store
const modalStore = useModalStore()
const { handleOpenConfirm } = modalStore
const isButtonDisabled = ref(false) // 버튼 비활성화 여부
/**
* STOVE 미로그인 상태일 경우 로그인 모달을 표시합니다.
* 케이스에 따라 모달 내용을 변경할 수 있습니다.
* @param content<string> 모달 내용(로그인 안내 메시지)
*/
const showLoginModal = (content: string) => {
handleOpenConfirm({
contentText: content,
confirmButtonText: tm('Download_Text_StoveLogin'),
modalName: 'modal-login',
confirmButtonEvent: () => {
csrGoStoveLogin()
},
})
}
/**
* PC사양 프로그램이 미설치일 경우 설치 안내 모달을 표시합니다.
* @param {string} setupUrl 설치 프로그램 URL
*/
const showInstallGuideModal = (setupUrl: string) => {
isButtonDisabled.value = false
handleOpenConfirm({
contentText: tm('Download_Alert_InstallGuide'),
confirmButtonText: tm('Download_Text_Download'),
modalName: 'modal-download',
isShowDimmed: true,
confirmButtonEvent: () => {
location.href = String(setupUrl)
},
})
}
/**
* iframe을 생성합니다.
* @returns {iframe<HTMLIFrameElement>} 생성된 iframe
*/
const createHiddenIframe = () => {
const iframe = document.createElement('iframe')
iframe.src = 'about:blank'
iframe.id = 'hiddenIframe'
iframe.style.display = 'none'
document.body.appendChild(iframe)
return iframe
}
/**
* Firefox 브라우저에서 URI를 열기 위한 함수입니다.
* @param {string} uri URI
* @param {Function} successCb 성공 콜백
* @param {Function} failCb 실패 콜백
*/
const openUriUsingFirefox = (
uri: string,
successCb: Function,
failCb: Function
) => {
let iframe = document.querySelector(
'#hiddenIframe'
) as HTMLIFrameElement | null
if (!iframe) {
iframe = createHiddenIframe() as HTMLIFrameElement
}
try {
iframe.contentWindow!.location.href = uri
successCb()
} catch (e) {
if ((e as any).name === 'NS_ERROR_UNKNOWN_PROTOCOL') {
failCb()
}
}
}
/**
* Chrome 브라우저에서 URI를 열기 위한 함수입니다.
* @param {string} uri 사양 프로그램 URI
* @param {Function} successCb 성공 콜백
* @param {Function} failCb 실패 콜백
*/
const openUriWithTimeoutHack = (
uri: string,
successCb: Function,
failCb: Function
) => {
let called = false
const timeout = setTimeout(() => {
if (!called) {
called = true
window.removeEventListener('blur', onBlur)
failCb()
}
}, 1000)
const onBlur = () => {
if (!called) {
called = true
clearTimeout(timeout)
window.removeEventListener('blur', onBlur)
successCb()
}
}
window.addEventListener('blur', onBlur)
window.location.href = uri
}
/**
* PC사양 프로그램이 설치되어 있는지 여부를 확인합니다.
* @param {string }uri 사양 프로그램 URI
* @param {string} setupUrl 설치 프로그램 URL
*/
const checkPCSpecProgramInstalled = (uri: string, setupUrl: string) => {
const device = useDevice()
const successCallback = () => {
isButtonDisabled.value = false
}
if (typeof (navigator as any).msLaunchUri === 'function') {
;(navigator as any).msLaunchUri(
uri,
successCallback,
showInstallGuideModal(setupUrl)
)
} else if (device.isFirefox) {
// 파이어폭스 브라우저일 경우
openUriUsingFirefox(uri, successCallback, () =>
showInstallGuideModal(setupUrl)
)
} else if (device.isChrome || device.isEdge) {
// 크롬 / 엣지 브라우저일 경우
openUriWithTimeoutHack(uri, successCallback, () =>
showInstallGuideModal(setupUrl)
)
} else {
// 기타 미지원 브라우저일 경우
showInstallGuideModal(setupUrl)
}
}
/**
* 시스템 사양 체크를 시작합니다.
* 최종 호출 함수입니다.
* @param {ReqCheckSpec} req {schemeFormat:스킴 포맷, setupUrl:셋업 링크, gameNo:게임넘버, locale:언어}
*/
const checkPCSpec = async (req: ReqCheckSpec) => {
const accessToken = useCookie('SUAT')
await validateToken(accessToken.value || '')
// 로그인 상태 아닐 경우
if (!isTokenValid.value) {
showLoginModal(tm('Download_Alert_StoveLogin'))
return
}
const localeValue: string = req.locale === 'ja' ? 'jp' : req.locale
const uri = `${req.schemeFormat}${accessToken.value}/${req.gameNo}/${localeValue}`
checkPCSpecProgramInstalled(uri, req.setupUrl)
isButtonDisabled.value = true
}
return {
isButtonDisabled,
checkPCSpec,
}
}

View File

@@ -9,6 +9,7 @@ import GrDetail02 from '#layers/templates/GrDetail02/index.vue'
import GrDetail03 from '#layers/templates/GrDetail03/index.vue'
import GrBoard01 from '#layers/templates/GrBoard01/index.vue'
import GrContents01 from '#layers/templates/GrContents01/index.vue'
import FxDownload01 from '#layers/templates/FxDownload01/index.vue'
const templateRegistry = {
GR_VISUAL_01: { component: GrVisual01 },
@@ -22,6 +23,7 @@ const templateRegistry = {
GR_DETAIL_02: { component: GrDetail02 },
GR_DETAIL_03: { component: GrDetail03 },
GR_CONTENTS_01: { component: GrContents01 },
FX_DOWNLOAD_01: { component: FxDownload01 },
} as const
type TemplateKey = keyof typeof templateRegistry

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,468 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import { getComponentGroup, getComponentGroupAry } from '#layers/utils/dataUtil'
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
// Types
type Platform = 'google_play' | 'app_store' | 'pc' | 'stove'
// Props
interface Props {
components: PageDataTemplateComponents
pageVerTmplSeq: number
}
const props = defineProps<Props>()
// Configuration
const runtimeConfig = useRuntimeConfig()
const runType = runtimeConfig.public.runType
const staticUrl = runtimeConfig.public.staticUrl
const multilingualBaseApiUrl = `${staticUrl}/${runType}/test`
const multilingualFileName = 'test_homepage_brand_download.json'
// Multilingual
const resultGetMultilingual = await useGetMultilingual({
baseApiUrl: multilingualBaseApiUrl,
fileName: multilingualFileName,
})
const { tm, locale }: any = useI18n({
useScope: 'local',
messages: Object(resultGetMultilingual?.value?.multilingual),
})
// Composables
const device = useDevice()
const breakpoints = useResponsiveBreakpoints()
const { checkPCSpec } = useCheckPCSpec(tm)
// Store
const gameDataStore = useGameDataStore()
const { gameData } = storeToRefs(gameDataStore)
// Data
const backgroundData = computed(() =>
getComponentGroup(props.components, 'background')
)
const specCheckData = computed(() =>
getComponentGroupAry(props.components, 'tablePcSpecTool')
)
const schemeFormatData = computed(
() => specCheckData.value[0]?.display?.text || ''
)
const setupUrlData = computed(() => specCheckData.value[1]?.display?.text || '')
// Refs
const specPCRef = ref<HTMLElement | null>(null)
const specMobileRef = ref<HTMLElement | null>(null)
const driverArray = ref<Array<string>>([
'DirectX',
'NVIDIA',
'AMD',
'intel',
'Visual',
])
const pcSpecArray = ref<Array<string>>([
'Os',
'Cpu',
'Vga',
'Ram',
'Directx',
'Hdd',
])
const mobileSpecArray = ref<Array<string>>(['Android', 'Ios'])
const mobileOSArray = ref<Array<string>>(['AOS', 'iOS'])
// Computed
const platformList = computed(() => {
if (breakpoints.value.isMobile) {
return ['MOBILE', 'PC', 'STOVE']
} else {
return ['PC', 'STOVE', 'MOBILE']
}
})
const driverList = computed(() =>
driverArray.value.map(driver => ({
id: `DRIVER_${driver}`,
driverCode: driver,
driverText: tm(`Download_Driver_${driver}`),
}))
)
const pcSpecList = computed(() =>
pcSpecArray.value.map(data => ({
id: `PC_TABLE_ITEM_${data}`,
itemCode: data,
itemText: tm(`Download_Table_${data}`),
itemData: getComponentGroupAry(props.components, `tablePcSpec${data}`),
}))
)
const mobileSpecList = computed(() =>
mobileSpecArray.value.map(data => ({
id: `MO_TABLE_ITEM_${data}`,
itemCode: data,
itemText: tm(`Download_Table_${data}`),
itemData: getComponentGroupAry(props.components, `tableMoSpec${data}`),
}))
)
const mobileOSList = computed(() =>
mobileOSArray.value.map(os => ({
id: `MO_OS_${os}`,
osCode: os,
osText: tm(`Download_${os}_OS`),
platformCode: tm(`Download_${os}_Platform`),
platformText: tm(`Download_${os}_Name`),
isValue: device[tm(`Download_${os}_IsValue`) as keyof typeof device],
}))
)
// Functions
/**
* 입력한 pc, mobile 인자값에 따라 해당 최소사양&권장사양 테이블로 포커스 스크롤 이동합니다.
* @param target<string> 'pc' | 'mobile'
*/
const handleMoveFocus = (target: 'pc' | 'mobile') => {
const refs: Record<string, HTMLElement | null> = {
pc: specPCRef.value,
mobile: specMobileRef.value,
}
const adjustedOffset: number = breakpoints.value.isMobile ? 80 : 100
const adjustedOffsetTop: number =
refs[target]?.getBoundingClientRect().top + window.scrollY - adjustedOffset
window.scrollTo({ top: adjustedOffsetTop, behavior: 'smooth' })
}
</script>
<template>
<WidgetsFixMainTitle
:title="tm('Download_Page_Title')"
:resourcesData="backgroundData"
/>
<div
class="overflow-hidden h-auto pt-[32px] px-[20px] pb-[80px] bg-[#F0F0F0] sm:px-[40px] md:pt-[64px] md:pb-[200px]"
>
<section class="relative flex flex-col items-center justify-start w-full">
<div class="w-full max-w-full md:max-w-[1300px]">
<WidgetsFixSubTitle :title="tm('Download_Section_Platform_Title')" />
<BlocksSlideDefault
:per-page="platformList.length"
:gap="20"
:arrows="false"
:pagination="false"
:drag="false"
:breakpoints="{
1023: {
autoWidth: true,
perPage: 'auto',
gap: 12,
focus: 0,
drag: true,
padding: { left: 0, right: 0 },
},
}"
class="w-[calc(100%+40px)] px-[20px] ml-[-20px] mt-[16px] sm:w-[calc(100%+80px)] sm:px-[40px] sm:ml-[-40px] md:w-full md:px-0 md:mt-[24px] md:ml-0"
>
<SplideSlide
v-for="platform in platformList"
:key="platform"
class="flex flex-col items-center justify-between shrink-0 whitespace-normal w-[295px] h-[280px] bg-[#FFFFFF] p-[20px] rounded-[12px] text-left md:w-[calc((100%-40px)/3)] md:h-[314px] md:p-[24px] md:rounded-[16px] lg:w-[420px] lg:h-[340px] lg:p-[32px]"
>
<div
class="flex flex-col items-start justify-start gap-[8px] w-full md:gap-[12px]"
>
<WidgetsFixSubTitle
:title="tm(`Download_Box_${platform}_Title`)"
/>
<p
v-if="
tm(`Download_Box_${platform}_Description_List`).length === 1
"
v-dompurify-html="tm(`Download_Box_${platform}_Description01`)"
class="text-[#999999] text-[14px] font-[400] leading-[24px] tracking-[-0.42px] md:text-[15px] md:tracking-[-0.45px]"
></p>
<ul
v-else-if="
tm(`Download_Box_${platform}_Description_List`).length > 1
"
>
<li
v-for="description in tm(
`Download_Box_${platform}_Description_List`
)"
:key="description"
v-dompurify-html="tm(description)"
class="relative pl-[22px] before:content-[''] before:absolute before:top-[10px] before:left-[9px] before:w-[3px] before:h-[3px] before:rounded-full before:bg-[#999999] text-[#999999] text-[14px] font-[400] leading-[24px] tracking-[-0.42px] md:text-[15px] md:tracking-[-0.45px]"
></li>
</ul>
<AtomsButton
v-if="platform !== 'STOVE'"
type="action"
button-size="size-small"
background-color="transparent"
text-color="#1F1F1F"
class="relative w-auto h-auto px-0 text-[16px] font-[500] leading-[24px] tracking-[-0.48px] before:content-[''] before:absolute before:z-[2] before:top-p before:left-0 before:w-full before:h-full before:bg-[#FFFFFF] before:transition-opacity before:duration-300 before:ease-in-out before:opacity-0 hover:before:opacity-20"
@click="
handleMoveFocus(platform.toLowerCase() as 'pc' | 'mobile')
"
>
<span>{{ tm(`Download_Box_${platform}_SpecCheck`) }}</span>
<AtomsIconsLongArrowRightLine
:size="20"
color="#1F1F1F"
class="relative rotate-90"
/>
</AtomsButton>
</div>
<div
class="flex flex-col items-center justify-center gap-[8px] w-full md:gap-[12px]"
>
<template v-if="platform === 'MOBILE'">
<template v-for="os in mobileOSList" :key="os.id">
<AtomsButtonLauncher
v-if="device.isMobile ? os.isValue : true"
:platform="`${os.platformCode as Platform}`"
class="!w-full"
>
<span>{{ os.platformText }}</span>
</AtomsButtonLauncher>
</template>
</template>
<template v-else>
<AtomsButton
v-if="breakpoints.isMobile"
type="action"
button-size="size-small"
background-color="#EBEBEB"
text-color="#999999"
:disabled="true"
class="w-full px-0 border border-solid border-[rgba(0,0,0,0.1)] cursor-default"
>
<span>{{ tm(`Download_Button_${platform}_Mobile`) }}</span>
</AtomsButton>
<AtomsButtonLauncher
v-else-if="breakpoints.isMd || breakpoints.isDesktop"
:platform="`${platform.toLowerCase() as Platform}`"
class="!w-full"
>
<span>{{ tm(`Download_Button_${platform}`) }}</span>
</AtomsButtonLauncher>
</template>
</div>
</SplideSlide>
</BlocksSlideDefault>
</div>
</section>
<section
ref="specPCRef"
class="relative flex flex-col items-center justify-start w-full mt-[80px] md:mt-[100px]"
>
<div class="w-full max-w-full md:max-w-[1300px]">
<WidgetsFixSubTitle :title="tm('Download_Section_PC_Title')" />
<div class="mt-[16px] md:mt-[24px]">
<table>
<thead>
<tr>
<th class="w-[80px] md:w-[172px]">
{{ tm('Download_Table_Item') }}
</th>
<th>{{ tm('Download_Table_Min_Spec') }}</th>
<th>{{ tm('Download_Table_Rec_Spec') }}</th>
</tr>
</thead>
<tbody>
<template v-for="tr in pcSpecList" :key="tr.id">
<tr>
<th class="w-[80px] md:w-[172px]">{{ tr.itemText }}</th>
<template v-for="td in tr.itemData" :key="td.display.text">
<td>{{ td.display.text }}</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
<div
v-if="!breakpoints.isMobile"
class="relative flex items-end justify-between w-full mt-[24px] gap-[24px]"
>
<ul
v-if="tm('Download_Section_PC_Notice_List').length > 0"
class="relative flex flex-col items-center justify-start w-full"
>
<template
v-for="notice in tm('Download_Section_PC_Notice_List')"
:key="notice"
>
<li
v-dompurify-html="tm(notice)"
class="relative w-full pl-[26px] text-left text-[#666666] text-[16px] font-[400] leading-[26px] tracking-[-0.48px] before:content-[''] before:absolute before:top-[11px] before:left-[10px] before:w-[4px] before:h-[4px] before:bg-[#666666] before:rounded-full"
></li>
</template>
</ul>
<AtomsButton
type="action"
button-size="size-small"
background-color="#383838"
text-color="#FFFFFF"
class="shrink-0 w-[206px] px-0 text-[16px]"
@click="
checkPCSpec({
schemeFormat: schemeFormatData,
setupUrl: setupUrlData,
gameNo: gameData?.game_code?.toString(),
locale: locale as string,
})
"
>
<em
class="inline-flex items-center justify-center gap-[8px] not-italic"
>
<span
class="relative inline-flex items-center justify-center w-[20px] h-[20px]"
>
<img
:src="
getImageHost(
'/images/common/ic-v2-hardware-desktop-line.svg',
{ imageType: 'common' }
)
"
alt="Desktop-Icon"
loading="lazy"
draggable="false"
class="w-full object-contain"
/>
</span>
<span>{{ tm('Download_Button_SpecCheck') }}</span>
</em>
</AtomsButton>
</div>
</div>
</section>
<section
v-if="!breakpoints.isMobile"
class="relative flex flex-col items-center justify-start w-full mt-[80px] md:mt-[100px]"
>
<div class="w-full max-w-full md:max-w-[1300px]">
<WidgetsFixSubTitle :title="tm('Download_Section_Driver_Title')" />
<div class="mt-[16px] md:mt-[24px]">
<ul class="flex items-center justify-between gap-[20px] w-full">
<template v-for="driver in driverList" :key="driver.id">
<li class="w-full h-[250px]">
<div
class="flex flex-col items-center justify-between gap-[16px] h-[250px] rounded-[16px] p-[20px] bg-[#FFFFFF]"
>
<div class="flex items-center justify-center w-full">
<img
:src="
getImageHost(
`/images/common/grades_driver/Type-${driver.driverCode}.svg`,
{ imageType: 'common' }
)
"
:alt="driver.driverText"
loading="lazy"
draggable="false"
/>
</div>
<div
class="flex flex-col items-start justify-end gap-[16px] w-full"
>
<p
class="text-left text-[#666666] text-[16px] font-[400] leading-[26px] tracking-[-0.48px]"
>
{{ driver.driverText }}
</p>
<AtomsButton
type="download"
button-size="size-small"
background-color="#383838"
text-color="#FFFFFF"
class="w-full px-0"
target="_blank"
rel="noopener noreferrer"
:href="tm(`Download_Driver_${driver.driverCode}_Url`)"
>
<span>{{ tm('Download_Button_Download') }}</span>
</AtomsButton>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
</section>
<section
ref="specMobileRef"
class="relative flex flex-col items-center justify-start w-full mt-[80px] md:mt-[100px]"
>
<div class="w-full max-w-full md:max-w-[1300px]">
<WidgetsFixSubTitle :title="tm('Download_Section_MOBILE_Title')" />
<div class="mt-[16px] md:mt-[24px]">
<table>
<thead>
<tr>
<th rowspan="2" class="w-[80px] md:w-[172px]">
{{ tm('Download_Table_Item') }}
</th>
<th colspan="2">{{ tm('Download_Table_Min_Spec') }}</th>
<th colspan="2">{{ tm('Download_Table_Rec_Spec') }}</th>
</tr>
<tr>
<th>{{ tm('Download_Table_Device') }}</th>
<th>{{ tm('Download_Table_Os') }}</th>
<th>{{ tm('Download_Table_Device') }}</th>
<th>{{ tm('Download_Table_Os') }}</th>
</tr>
</thead>
<tbody>
<template v-for="tr in mobileSpecList" :key="tr.id">
<tr>
<th class="w-[80px] md:w-[172px]">{{ tr.itemText }}</th>
<template v-for="td in tr.itemData" :key="td.display.text">
<td>{{ td.display.text }}</td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</section>
</div>
</template>
<style scoped>
/* 고정형 페이지 Table Style */
table {
@apply w-full h-auto border-collapse border-spacing-0 table-fixed;
}
table th {
@apply py-[8px] px-[12px] border border-[#D9D9D9] bg-[#FAFAFA] text-[#1F1F1F] text-[14px] font-bold leading-[24px] tracking-[-0.42px]
md:py-[11px] md:px-[20px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px];
}
table td {
@apply h-[64px] py-[8px] px-[12px] border border-[#D9D9D9] bg-[#FFFFFF] text-[#666666] text-[14px] font-[400] leading-[24px] tracking-[-0.42px]
md:h-[80px] md:py-[14px] md:px-[20px] md:text-[16px] md:leading-[26px] md:tracking-[-0.48px];
}
/* 플랫폼별 다운로드 Mobile Overflow Visible 처리 */
.splide :deep(.splide__track) {
overflow: visible !important;
}
</style>