feat. header 컴포넌트 반응형 제작

This commit is contained in:
clkim
2025-10-01 11:23:13 +09:00
parent 5f140aced1
commit 517d2b233b
17 changed files with 514 additions and 107 deletions

View File

@@ -9,6 +9,8 @@ const nuxtApp = useNuxtApp()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const scrollStore = useScrollStore()
const { youtube, handleResetYoutube } = modalStore
const { setGameData } = gameDataStore
const { gameData } = storeToRefs(gameDataStore)
@@ -112,12 +114,19 @@ if (serverGameData) {
setupMetaData(serverGameData)
}
const { gtag, initialize } = useGtag()
initialize(gameData.value.ga_code)
initialize(gameData.value?.ga_code)
gtag('event', 'screen_view', {
app_name: 'My App',
screen_name: 'Home'
screen_name: 'Home',
})
onMounted(() => {
useEventListener('scroll', scrollStore.updateScrollValue)
})
onBeforeUnmount(() => {
removeEventListener('scroll', scrollStore.updateScrollValue)
})
</script>
<template>

View File

@@ -4,10 +4,11 @@
--foreground-10: #ffffff;
--foreground-reversal: #1f1f1f;
--foreground-reversal-4: rgba(0, 0, 0, 0.04);
--foreground-reversal-6: rgba(0, 0, 0, 0.06);
--foreground-reversal-8: rgba(0, 0, 0, 0.08);
--foreground-reversal-10: rgba(0, 0, 0, 0.1);
--foreground-reversal-30: #ebebeb; /* gray-80 */
--foreground-reversal-40: rgba(0, 0, 0, 0.4);
--foreground-reversal-70: #666666; /* gray-700 */
}
/* 다크 테마 색상 */
@@ -16,10 +17,11 @@
--foreground-10: #292929;
--foreground-reversal: #ebebeb;
--foreground-reversal-4: rgba(255, 255, 255, 0.04);
--foreground-reversal-6: rgba(255, 255, 255, 0.06);
--foreground-reversal-8: rgba(255, 255, 255, 0.08);
--foreground-reversal-10: rgba(255, 255, 255, 0.1);
--foreground-reversal-30: #404040; /* gray-750 */
--foreground-reversal-40: rgba(255, 255, 255, 0.4);
--foreground-reversal-70: #b2b2b2; /* gray-300 */
}
/* 커스텀 컴포넌트 스타일 */

View File

@@ -50,7 +50,7 @@ const props = defineProps<Props>()
@apply transition-transform duration-300 w-full h-full object-cover;
}
.card-overlay {
@apply absolute bottom-0 left-0 right-0 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] md:px-[26px] md:pb-[26px];
@apply absolute bottom-0 left-0 right-0 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-lg md:leading-[26px];

View File

@@ -1,24 +1,75 @@
<script setup lang="ts">
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type { GameDataValue, GameDataGnb } from '#layers/types/api/gameData'
import { useScrollStore } from '#layers/stores/useScrollStore'
import { useWindowScroll, onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type {
GameDataValue,
GameDataMenu,
GameDataMenuChildren,
} from '#layers/types/api/gameData'
const route = useRoute()
const { y: windowY } = useWindowScroll()
const scrollStore = useScrollStore()
const { isPassedStoveGnb } = storeToRefs(scrollStore)
const gameDataStore = useGameDataStore()
const gameData = gameDataStore.gameData as GameDataValue
const gnbList = gameData?.gnb?.menus as GameDataGnb['menus']
const gnbList = (gameData?.gnb?.menus ?? []) as GameDataMenuChildren
const isMenuOpen = ref(false)
const contentNavRef = ref<HTMLElement>()
const currentPath = computed(() => formatPathWithoutLocale(route.path))
const pathMatches = (base: string, current: string) => {
if (!base || base === '/') return current === '/'
return current === base || current.startsWith(base + '/')
}
/** header overlay 높이 계산 (re-compute 최소화) */
const scrollPositionPX = computed(() => {
const gnbHeight = scrollStore.stoveGnbHeight
const y = windowY.value
if (y === 0) return `${gnbHeight}px`
if (y >= gnbHeight) return '0px'
return `${gnbHeight - y}px`
})
/** 자식 중 활성 링크 존재 여부 */
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)
}
const handleMenuOpen = () => (isMenuOpen.value = true)
const handleMenuClose = () => (isMenuOpen.value = false)
onClickOutside(contentNavRef, () => (isMenuOpen.value = false))
</script>
<template>
<header
class="bg-theme-foreground text-theme-foreground-reversal relative z-50"
>
<BlocksStoveGnb />
<div
data-name="header-game"
class="px-[40px] h-16 flex items-center whitespace-nowrap"
>
<header class="header">
<BlocksStoveGnb class="min-h-[48px]" />
<div class="header-game" :class="{ 'game-fixed': isPassedStoveGnb }">
<!-- 로고 -->
<div data-name="header-logo" class="mr-[40px]">
<div class="header-logo">
<AtomsLocaleLink to="/brand">
<img
:src="gameData?.gnb?.bi_path"
@@ -27,70 +78,208 @@ const gnbList = gameData?.gnb?.menus as GameDataGnb['menus']
/>
</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"
<button
class="btn-open"
aria-controls="site-menu"
:aria-expanded="isMenuOpen"
@click="handleMenuOpen"
>
<span class="sr-only">menu open</span>
</button>
<div
class="header-content"
:class="{ 'menu-open': isMenuOpen }"
:style="{ '--scroll-position': scrollPositionPX }"
>
<div id="site-menu" ref="contentNavRef" class="content-nav">
<button
class="btn-close"
aria-controls="site-menu"
:aria-expanded="isMenuOpen"
@click="handleMenuClose"
>
<!-- Link 컴포넌트 사용 -->
<BlocksHybridLink
: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"
<span class="sr-only">menu close</span>
</button>
<div class="nav-logo">
<AtomsLocaleLink to="/brand">
<img
:src="gameData?.gnb?.bi_path"
:alt="gameData?.game_name"
class="h-[30px]"
/>
</BlocksHybridLink>
<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 컴포넌트 사용 -->
<BlocksHybridLink
: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"
/>
</BlocksHybridLink>
</li>
</ul>
</div>
</AtomsLocaleLink>
</div>
</template>
<nav class="nav-main">
<div v-if="gnbList" class="main-official">
<div
v-for="(gnbItem, key) in gnbList"
:key="key"
class="nav-item"
>
<BlocksHybridLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`item-1depth ${isNavItemActive(gnbItem) ? 'active' : ''}`"
>
<span>{{ gnbItem.menu_name }}</span>
<AtomsIconsArrowDown v-if="gnbItem.children" />
</BlocksHybridLink>
<!-- 구분선 -->
<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 v-if="gnbItem.children" class="item-2depth">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<BlocksHybridLink
:to="child.url_path"
:target="child.link_target"
>
<span>{{ child.menu_name }}</span>
<AtomsIconsLinkOut
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</li>
</ul>
</div>
</div>
</div>
<div class="main-event">
<a href="#" class="item-1depth text-gradient-pink">
<AtomsIconsStar />
<span>이벤트</span>
<AtomsIconsStar />
</a>
</div>
</nav>
<div class="btn-start">
<AtomsButton size="small" class="w-full md:w-auto">
게임 시작
</AtomsButton>
</div>
</div>
</div>
</div>
</header>
</template>
<style scoped>
.header {
@apply bg-theme-foreground text-theme-foreground-reversal relative z-50;
}
.header-game {
@apply absolute flex w-full h-[48px] items-center whitespace-nowrap px-[64px] bg-theme-foreground md:h-16 md:px-[40px]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-theme-foreground-reversal-6;
}
.game-fixed {
@apply fixed top-0 left-0;
}
.header-logo {
@apply mx-auto shrink-0 md:mx-0;
}
.btn-open,
.btn-close {
@apply absolute w-[40px] h-[40px] md:hidden bg-[red];
}
.btn-open {
@apply top-[4px] left-[12px];
}
.btn-close {
@apply top-[11px] left-[12px];
}
.btn-start {
@apply relative mt-2 px-5 md:ml-auto md:mt-0 md:px-0;
}
.header-content {
@apply overflow-hidden absolute top-0 left-0 w-0 md:overflow-visible md:static md:w-full md:h-full;
height: calc(100vh - var(--scroll-position));
}
.header-content.menu-open {
@apply w-full;
}
.header-content.menu-open::before {
@apply content-[''] absolute inset-0 w-[100vw] h-full bg-[rgba(0,0,0,0.6)] md:hidden;
}
.header-content.menu-open .content-nav {
@apply relative h-full translate-x-0 transition-transform duration-300 md:translate-x-0;
}
.content-nav {
@apply relative flex flex-col w-[360px] bg-theme-foreground-10 translate-x-[-100%]
md:flex-row md:w-full md:items-center md:h-full md:bg-transparent md:translate-x-0;
}
.nav-main {
@apply overflow-hidden flex flex-col order-1 h-full mt-2 mb-4 px-2
md:overflow-visible md:flex-row md:order-none md:h-full md:my-0 md:mx-10 md:px-0;
}
.main-official {
@apply overflow-y-auto pb-2 md:overflow-visible md:flex md:items-center md:space-x-8 md:pb-0;
}
.main-event {
@apply relative flex flex-col mb-2 md:flex-row md:mb-0 md:ml-16;
}
.main-event::before {
@apply content-[''] hidden absolute top-[23px] left-[-32px] w-px h-4 bg-theme-foreground-reversal-30 md:block;
}
.main-event::after {
@apply order-[-1] content-[''] h-px mb-2 mx-3 bg-theme-foreground-reversal-8
md:mb-0 md:mx-0 md:absolute md:bottom-0 md:left-0 md:w-full md:h-[2px] md:bg-theme-foreground-reversal md:opacity-0;
}
.main-event:hover::after {
@apply opacity-100;
}
.nav-logo {
@apply flex items-center justify-center shrink-0 h-[62px] md:hidden;
}
.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 .item-1depth::after,
.item-1depth.active::after {
@apply opacity-100 md:transition-opacity;
}
.nav-item:hover .item-2depth {
@apply md:opacity-100 md:visible md:pointer-events-auto md:transition-opacity md:duration-200;
}
.item-1depth {
@apply flex items-center py-[9px] px-3 gap-1 rounded-[12px] md:h-full md:py-0 md:px-0;
}
.item-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;
}
.item-1depth.active {
@apply bg-theme-foreground-reversal-8 md:bg-transparent;
}
.item-2depth {
@apply block text-[15px] md:opacity-0 md:absolute md:top-full md:left-[-28px] md:min-w-[190px] md:pt-1 md:pointer-events-none;
}
.item-2depth ul {
@apply bg-theme-foreground-10 rounded-[20px] md:shadow-lg md:p-3;
}
.item-2depth a {
@apply flex items-center gap-1 px-5 py-[9px] rounded-[12px] transition-colors
hover:bg-theme-foreground-reversal-4 active:bg-theme-foreground-reversal-10
md:px-4 md:py-[11px];
}
@media (min-width: 1024px) {
.header-content {
height: 100%;
}
}
</style>

View File

@@ -57,7 +57,7 @@ watchEffect(() => {
</script>
<template>
<main>
<main class="main">
<template
v-for="(template, index) in visibleTemplates"
:key="template.template_code ?? index"
@@ -70,3 +70,9 @@ watchEffect(() => {
</template>
</main>
</template>
<style scoped>
.main {
@apply pt-[48px] md:pt-[64px];
}
</style>

View File

@@ -4,7 +4,7 @@ import { useWindowScroll } from '@vueuse/core'
export const useScrollStore = defineStore('scrollStore', () => {
const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' })
const stoveGnbHeight = 48
const stoveGnbHeight = 48 as number
const scrollXValue = ref('0px')
const isPassedStoveGnb = ref(false)
@@ -27,6 +27,7 @@ export const useScrollStore = defineStore('scrollStore', () => {
}
return {
stoveGnbHeight,
scrollXValue,
isPassedStoveGnb,

View File

@@ -47,7 +47,6 @@ const { data: resourcesData } = await useLazyAsyncData(
}
)
// 배너 리스트 데이터 추출
const slideData = computed(() => {
const operateComponents = resourcesData.value?.operate_components
@@ -80,7 +79,7 @@ const slideItemSize = {
</script>
<template>
<section class="pt-[140px] pb-[80px] md:pt-[200px] md:pb-[120px]">
<section class="relative pt-[140px] pb-[80px] md:pt-[200px] md:pb-[120px]">
<WidgetsBackground
v-if="backgroundData"
:resources-data="backgroundData"
@@ -108,15 +107,16 @@ const slideItemSize = {
class="mt-[48px] md:mt-[72px]"
/>
<BlocksSlideCenterHighlight
v-if="slideData"
:slide-item-size="slideItemSize"
:slide-item-length="slideData.length"
:slide-item-length="slideData?.length"
:pagination="false"
class="mt-[36px] md:mt-[60px]"
>
<SplideSlide v-for="(item, index) in slideData" :key="index">
<BlocksCardNews
:title="item.title"
:description="item.option01"
:description="formatTimestamp(item.reg_dt, 'YYYY.MM.DD')"
:img-path="getResolvedHost(item.img_path)"
:url="item.url"
:link-target="item.link_target"

View File

@@ -116,6 +116,8 @@ export interface GameDataButton {
button: string // JSON 문자열로 변경
}
export type GameDataMenuChildren = Record<string, GameDataMenu>
// 메뉴 타입
export interface GameDataMenu {
path_code: string
@@ -125,7 +127,7 @@ export interface GameDataMenu {
click_action_type: number
url_path: string
link_target: string
children: Record<string, GameDataMenu> // 중첩 메뉴를 위한 children 속성 추가
children: GameDataMenuChildren
tracking: string | GameDataTracking // JSON 문자열 또는 객체로 변경
}
@@ -136,7 +138,7 @@ export interface GameDataGnb {
bi_path: string
lang_codes: string // JSON 문자열로 변경
buttons: GameDataButton[]
menus: Record<string, GameDataMenu> // 동적 객체로 변경
menus: GameDataMenuChildren
}
// 인트로 타입

View File

@@ -10,6 +10,7 @@ export interface ListOperateGroupItem {
url: string
link_target: string
display_status: number
reg_dt?: number
option01: number
option02: number
option03: string

View File

@@ -1,3 +1,8 @@
/**
* API 유틸리티 함수
* @description API 호출에 필요한 유틸리티 함수를 제공합니다.
*/
import type {
HttpMethod,
FetchOptions,

View File

@@ -1,3 +1,8 @@
/**
* 데이터 유틸리티 함수
* @description gameData, pageData 처리에 필요한 유틸리티 함수를 제공합니다.
*/
import type {
PageDataValue,
PageDataComponent,
@@ -63,3 +68,13 @@ export const getComponentGroupAry = (source: any, componentName: string) => {
return source[componentName]?.groups || []
}
/**
* 현재 시간의 타임스탬프를 반환합니다.
* @param unit 단위 ('ms' | 's') - 밀리초 또는 초
* @returns 타임스탬프
*/
export const getCurrentTimestamp = (unit: 'ms' | 's' = 'ms'): number => {
const now = Date.now()
return unit === 's' ? Math.floor(now / 1000) : now
}

180
layers/utils/formatUtil.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* 포맷 유틸리티 함수
* @description 포맷 처리에 필요한 유틸리티 함수를 제공합니다.
*/
/**
* JWT 디코딩
* @param base64EncodeVal JWT 인코딩 값
* @returns JWT 디코딩 값
*/
export const csrFormatJWT = (base64EncodeVal: string) => {
const decodeVal = JSON.parse(
decodeURIComponent(
window
.atob(base64EncodeVal)
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
})
.join('')
)
)
return decodeVal
}
/**
* 타임스탬프를 다양한 날짜 형식으로 변환합니다.
* @param timestamp 타임스탬프 (밀리초 또는 초)
* @param format 날짜 형식 ('YYYY-MM-DD', 'YYYY-MM-DD HH:mm:ss', 'MM/DD/YYYY', 'YYYY년 MM월 DD일' 등)
* @param locale 로케일 (기본값: 'ko-KR')
* @returns 포맷된 날짜 문자열
*/
export const formatTimestamp = (
timestamp: number | string,
format: string = 'YYYY.MM.DD',
_locale: string = 'ko-KR'
): string => {
if (!timestamp) return ''
// 타임스탬프를 숫자로 변환
let ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
// 초 단위인 경우 밀리초로 변환
if (ts < 10000000000) {
ts = ts * 1000
}
const date = new Date(ts)
// 유효하지 않은 날짜인 경우 빈 문자열 반환
if (isNaN(date.getTime())) {
return ''
}
// 미리 정의된 형식들
const predefinedFormats: Record<string, string> = {
'YYYY.MM.DD': date.toISOString().split('T')[0].replace(/-/g, '.'),
'YYYY-MM-DD': date.toISOString().split('T')[0],
'YYYY-MM-DD HH:mm': date.toISOString().replace('T', ' ').substring(0, 16),
'YYYY-MM-DD HH:mm:ss': date.toISOString().replace('T', ' ').split('.')[0],
'MM/DD/YYYY': date.toLocaleDateString('en-US'),
'DD/MM/YYYY': date.toLocaleDateString('en-GB'),
'YYYY년 MM월 DD일': date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
'MM월 DD일': date.toLocaleDateString('ko-KR', {
month: 'long',
day: 'numeric',
}),
}
// 미리 정의된 형식이 있으면 사용
if (predefinedFormats[format]) {
return predefinedFormats[format]
}
// 커스텀 형식 처리
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 타임스탬프를 상대적 시간으로 변환합니다 (예: "3일 전", "2시간 전")
* @param timestamp 타임스탬프 (밀리초 또는 초)
* @param locale 로케일 (기본값: 'ko-KR')
* @returns 상대적 시간 문자열
*/
export const formatRelativeTime = (
timestamp: number | string,
locale: string = 'ko-KR'
): string => {
if (!timestamp) return ''
let ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
if (ts < 10000000000) {
ts = ts * 1000
}
const date = new Date(ts)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return locale === 'ko-KR' ? '방금 전' : 'just now'
}
const diffInMinutes = Math.floor(diffInSeconds / 60)
if (diffInMinutes < 60) {
return locale === 'ko-KR'
? `${diffInMinutes}분 전`
: `${diffInMinutes} minutes ago`
}
const diffInHours = Math.floor(diffInMinutes / 60)
if (diffInHours < 24) {
return locale === 'ko-KR'
? `${diffInHours}시간 전`
: `${diffInHours} hours ago`
}
const diffInDays = Math.floor(diffInHours / 24)
if (diffInDays < 30) {
return locale === 'ko-KR' ? `${diffInDays}일 전` : `${diffInDays} days ago`
}
const diffInMonths = Math.floor(diffInDays / 30)
if (diffInMonths < 12) {
return locale === 'ko-KR'
? `${diffInMonths}개월 전`
: `${diffInMonths} months ago`
}
const diffInYears = Math.floor(diffInMonths / 12)
return locale === 'ko-KR' ? `${diffInYears}년 전` : `${diffInYears} years ago`
}
/**
* 배열 또는 객체를 배열로 변환합니다.
* @param value 변환할 값 (배열, 객체, 또는 undefined/null)
* @returns 배열
*/
export const formatToArray = <T>(
value: T[] | Record<string, T> | undefined | null
): T[] => {
if (!value) return []
return Array.isArray(value) ? value : Object.values(value)
}
/**
* URL 경로에서 로케일 접두사를 제거합니다.
* @param path 경로 문자열
* @returns 로케일 접두사가 제거된 경로
*/
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')
}

View File

@@ -1,15 +0,0 @@
export const csrDecodedJWT = (base64EncodeVal: string) => {
const decodeVal = JSON.parse(
decodeURIComponent(
window
.atob(base64EncodeVal)
.split('')
.map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
})
.join('')
)
)
return decodeVal
}

View File

@@ -1,4 +1,9 @@
import { csrDecodedJWT } from '#layers/utils/jwtUtil'
/**
* Stove 유틸리티 함수
* @description Stove 관련 유틸리티 함수를 제공합니다.
*/
import { csrFormatJWT } from '#layers/utils/formatUtil'
/**
* Stove 로그인
@@ -25,7 +30,7 @@ export const csrGetStoveMemberNo = () => {
if (suat.value && suat.value !== '') {
const base64Payload = suat.value?.split('.')[1] ?? ''
const decodeVal = csrDecodedJWT(base64Payload)
const decodeVal = csrFormatJWT(base64Payload)
memberNo = Number(`${decodeVal.member_no}`) || 0
} else {
memberNo = 0

View File

@@ -1,3 +1,8 @@
/**
* 스타일 유틸리티 함수
* @description 스타일 처리에 필요한 유틸리티 함수를 제공합니다.
*/
import type { PageDataResourceGroupResPath } from '#layers/types/api/pageData'
/**

View File

@@ -1,6 +1,7 @@
// ============================================================================
// 유튜브 관련 유틸리티
// ============================================================================
/**
* 유튜브 유틸리티 함수
* @description 유튜브 관련 유틸리티 함수를 제공합니다.
*/
/**
* 유튜브 URL에서 비디오 ID를 추출합니다.

View File

@@ -16,10 +16,11 @@ export default {
'theme-foreground-10': 'var(--foreground-10)',
'theme-foreground-reversal': 'var(--foreground-reversal)',
'theme-foreground-reversal-4': 'var(--foreground-reversal-4)',
'theme-foreground-reversal-6': 'var(--foreground-reversal-6)',
'theme-foreground-reversal-8': 'var(--foreground-reversal-8)',
'theme-foreground-reversal-10': 'var(--foreground-reversal-10)',
'theme-foreground-reversal-30': 'var(--foreground-reversal-30)',
'theme-foreground-reversal-40': 'var(--foreground-reversal-40)',
'theme-foreground-reversal-70': 'var(--foreground-reversal-70)',
},
},
},