diff --git a/layers/assets/css/app.css b/layers/assets/css/app.css index 77e7eea..86458e7 100644 --- a/layers/assets/css/app.css +++ b/layers/assets/css/app.css @@ -1,5 +1,7 @@ @import './base/_theme.css'; @import './base/_reset.css'; +@import './components/_swiper-pagination.css'; +@import './components/_button.css'; @tailwind base; @tailwind components; diff --git a/layers/assets/css/components/_button.css b/layers/assets/css/components/_button.css new file mode 100644 index 0000000..669f90b --- /dev/null +++ b/layers/assets/css/components/_button.css @@ -0,0 +1,44 @@ +/* Button Size Classes */ +@layer components { + .btn-base { + @apply relative inline-flex items-center justify-center font-medium border border-gray-600/30 overflow-hidden; + /* 기본 크기: size-medium */ + --btn-padding: theme('spacing.10'); + --btn-height: theme('spacing.14'); + --btn-text: theme('fontSize.base'); + --btn-radius: theme('borderRadius.lg'); + @apply px-10 h-14 text-base rounded-lg; + } + + .size-extra-small { + --btn-padding: theme('spacing.6'); + --btn-height: theme('spacing.10'); + --btn-text: theme('fontSize.sm'); + --btn-radius: theme('borderRadius.DEFAULT'); + @apply px-6 h-10 text-sm rounded; + } + + .size-small { + --btn-padding: theme('spacing.10'); + --btn-height: theme('spacing.12'); + --btn-text: theme('fontSize.sm'); + --btn-radius: theme('borderRadius.lg'); + @apply px-10 h-12 text-sm rounded-lg; + } + + .size-medium { + --btn-padding: theme('spacing.10'); + --btn-height: theme('spacing.14'); + --btn-text: theme('fontSize.base'); + --btn-radius: theme('borderRadius.lg'); + @apply px-10 h-14 text-base rounded-lg; + } + + .size-large { + --btn-padding: theme('spacing.10'); + --btn-height: theme('spacing.16'); + --btn-text: theme('fontSize.lg'); + --btn-radius: theme('borderRadius.lg'); + @apply px-10 h-16 text-lg rounded-lg; + } +} diff --git a/layers/assets/css/components/_swiper-pagination.css b/layers/assets/css/components/_swiper-pagination.css new file mode 100644 index 0000000..af4e031 --- /dev/null +++ b/layers/assets/css/components/_swiper-pagination.css @@ -0,0 +1,73 @@ +/* 페이지네이션 버튼 */ +main .slide-pagination.swiper-pagination-bullets { + position: absolute; + bottom: 48px; + left: 0; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + gap: 24px; + z-index: 5; +} + +.slide-pagination .swiper-pagination-bullet { + position: relative; + width: 12px; + height: 12px; + background: var(--primary); + border-radius: 50%; + opacity: 1; +} + +.slide-pagination .swiper-pagination-bullet:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(0, 0, 0, 0.5); +} + +.slide-pagination .swiper-pagination-bullet-active:after { + display: none; +} + +/* 네비게이션 버튼 */ +.slide-prev, +.slide-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + cursor: pointer; + z-index: 5; + + background-color: var(--primary); +} + +.slide-prev { + left: 40px; +} + +.slide-next { + right: 40px; +} + +@media (max-width: 1023px) { + main .slide-pagination.swiper-pagination-bullets { + bottom: 32px; + gap: 12px; + } + + .slide-pagination .swiper-pagination-bullet { + width: 8px; + height: 8px; + } +} diff --git a/layers/components/atoms/Button.vue b/layers/components/atoms/Button.vue index 1f06333..7c35a21 100644 --- a/layers/components/atoms/Button.vue +++ b/layers/components/atoms/Button.vue @@ -1,13 +1,14 @@ diff --git a/layers/components/blocks/modal/YouTube.vue b/layers/components/blocks/modal/YouTube.vue index 14f0c1b..966b05d 100644 --- a/layers/components/blocks/modal/YouTube.vue +++ b/layers/components/blocks/modal/YouTube.vue @@ -72,7 +72,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits() -// YouTube URL을 임베드 가능한 형태로 변환 +// [TODO] YouTube URL을 임베드 가능한 형태로 변환 const embedUrl = computed(() => { if (!props.youtubeUrl) return '' diff --git a/layers/components/layouts/Main.vue b/layers/components/layouts/Main.vue index 82b1117..8c81326 100644 --- a/layers/components/layouts/Main.vue +++ b/layers/components/layouts/Main.vue @@ -65,6 +65,7 @@ watchEffect(() => { diff --git a/layers/composables/useGetGameAlias.ts b/layers/composables/useGetGameAlias.ts deleted file mode 100644 index 55c6c46..0000000 --- a/layers/composables/useGetGameAlias.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { getHeader } from 'h3' -import { useRuntimeConfig, useRequestEvent } from 'nuxt/app' - -/** - * 게임 별칭을 추출하는 유틸리티 함수 - * @param host 호스트 문자열 - * @param baseDomain 기본 도메인 - * @returns 게임 별칭 또는 빈 문자열 - */ -const extractGameAliasFromHost = (host: string, baseDomain: string): string => { - if (!host || !host.includes(baseDomain)) { - return '' - } - - const subdomain = host.split('.')[0] - return subdomain && subdomain !== 'www' ? subdomain : '' -} - -/** - * 서버 사이드에서 게임 별칭을 가져오는 함수 - * @param baseDomain 기본 도메인 - * @returns 게임 별칭 또는 빈 문자열 - */ -const getGameAliasFromServer = (baseDomain: string): string => { - try { - const event = useRequestEvent() - - if (!event) { - return '' - } - - // 미들웨어에서 설정한 gameAlias가 있다면 우선 사용 - if (event.context.gameAlias) { - return event.context.gameAlias - } - - const host = getHeader(event, 'host') || '' - return extractGameAliasFromHost(host, baseDomain) - } catch (error) { - // eslint-disable-next-line no-console - console.error('useGetGameAlias server error:', error) - return '' - } -} - -/** - * 클라이언트 사이드에서 게임 별칭을 가져오는 함수 - * @param baseDomain 기본 도메인 - * @returns 게임 별칭 또는 빈 문자열 - */ -const getGameAliasFromClient = (baseDomain: string): string => { - try { - const host = window.location.host - return extractGameAliasFromHost(host, baseDomain) - } catch (error) { - // eslint-disable-next-line no-console - console.error('useGetGameAlias client error:', error) - return '' - } -} - -/** - * 게임 별칭을 가져오는 컴포저블 함수 - * 서버와 클라이언트 환경에서 모두 동작 - * @returns 게임 별칭 문자열 - */ -export const useGetGameAlias = (): string => { - const config = useRuntimeConfig() - const baseDomain = (config.public.baseDomain || '.onstove.com') as string - - if (import.meta.client) { - return getGameAliasFromClient(baseDomain) - } - - return getGameAliasFromServer(baseDomain) -} diff --git a/layers/composables/useGetGameDomain.ts b/layers/composables/useGetGameDomain.ts new file mode 100644 index 0000000..353352f --- /dev/null +++ b/layers/composables/useGetGameDomain.ts @@ -0,0 +1,35 @@ +import { getHeader, getRequestHost } from 'h3' +import { useRequestEvent } from 'nuxt/app' + +/** + * 게임 도메인을 가져오는 컴포저블 함수 + * 서버와 클라이언트 환경에서 모두 동작 + * @returns 게임 도메인 문자열 + */ +export const useGetGameDomain = (): string => { + try { + if (import.meta.client) { + const host = window.location.host || '' + return host.split(':')[0] + } + + const event = useRequestEvent() + if (!event) { + return '' + } + + // 미들웨어에서 설정한 gameDomain가 있다면 우선 사용 + if (event.context.gameDomain) { + return event.context.gameDomain + } + + const host = + (getHeader(event, 'host') || getRequestHost(event)).toString() || '' + const cleanHost = host.split(':')[0] + + return cleanHost || '' + } catch (error) { + console.error('useGetGameDomain error:', error) + return '' + } +} diff --git a/layers/middleware/pageData.global.ts b/layers/middleware/pageData.global.ts index 0fcded0..2bd020e 100644 --- a/layers/middleware/pageData.global.ts +++ b/layers/middleware/pageData.global.ts @@ -1,13 +1,13 @@ import { commonFetch } from '#layers/utils/apiUtil' import { usePageDataStore } from '#layers/stores/usePageDataStore' -import { useGetGameAlias } from '#layers/composables/useGetGameAlias' +import { useGetGameDomain } from '@/layers/composables/useGetGameDomain' import { usePathResolver } from '#layers/composables/usePathResolver' import type { PageDataResponse } from '#layers/types/api/pageData' export default defineNuxtRouteMiddleware(async (to, _from) => { const config = useRuntimeConfig() const store = usePageDataStore() - const gameAlias = useGetGameAlias() + const gameDomain = useGetGameDomain() const { getPathAfterLanguage } = usePathResolver() const stoveApiBaseUrl = config.public.stoveApiUrl @@ -22,7 +22,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => { } const queryParams: Record = { - game_alias: gameAlias, + game_domain: gameDomain, lang_code: 'ko', page_url: pageUrl, _t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프 diff --git a/layers/server/middleware/gameInfo.ts b/layers/server/middleware/gameInfo.ts index 013df7f..62cbfd5 100644 --- a/layers/server/middleware/gameInfo.ts +++ b/layers/server/middleware/gameInfo.ts @@ -22,14 +22,11 @@ export default defineEventHandler(async event => { const host = (getHeader(event, 'host') || getRequestHost(event)).toString() || '' const baseDomain = process.env.BASE_DOMAIN || '.onstove.com' - const isGameAliasExtractable = host.includes(baseDomain) + const isGameDomainExtractable = host.includes(baseDomain) - if (isGameAliasExtractable) { - const gameAlias = host.split('.')[0] - - if (gameAlias && gameAlias !== 'www') { - event.context.gameAlias = gameAlias - } + if (isGameDomainExtractable) { + const cleanHost = host.split(':')[0] + event.context.gameDomain = cleanHost } // gameData를 직접 가져와서 context에 저장 (API 호출 없이) @@ -52,7 +49,7 @@ export default defineEventHandler(async event => { const langCode = pathSegments[0] || 'ko' const queryParams: Record = { - game_alias: event.context.gameAlias || '', + game_domain: event.context.gameDomain || '', lang_code: langCode, } @@ -63,7 +60,7 @@ export default defineEventHandler(async event => { // 타입 단언을 사용하여 response의 타입 오류를 해결 const res = response as { code?: number; value?: unknown } - if (res?.code === 0 && 'value' in res) { + if (res?.code === 0 && res && typeof res === 'object' && 'value' in res) { event.context.gameData = res.value } } catch (error) { diff --git a/layers/templates/GrVisual01/index.vue b/layers/templates/GrVisual01/index.vue index 1922597..037c1d8 100644 --- a/layers/templates/GrVisual01/index.vue +++ b/layers/templates/GrVisual01/index.vue @@ -1,6 +1,7 @@ diff --git a/package.json b/package.json index ccdf8ef..36de89a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "h3": "^1.15.4", "nuxt": "^4.0.3", "pinia": "^2.3.1", + "swiper": "^12.0.1", "vue": "^3.5.0", "vue-dompurify-html": "^5.3.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8b024b..ffd0eda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: pinia: specifier: ^2.3.1 version: 2.3.1(typescript@5.9.2)(vue@3.5.21(typescript@5.9.2)) + swiper: + specifier: ^12.0.1 + version: 12.0.1 vue: specifier: ^3.5.0 version: 3.5.21(typescript@5.9.2) @@ -4171,6 +4174,10 @@ packages: engines: {node: '>=16'} hasBin: true + swiper@12.0.1: + resolution: {integrity: sha512-Fi+gNw/tfc4hsGowQU5tRC/f1HFknkh4Vz8PaDI4JTinLUMTwhZyaovcH/va+iXq98BNUHN5ok0c2lEI82Fsgw==} + engines: {node: '>= 4.7.0'} + synckit@0.11.11: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9190,6 +9197,8 @@ snapshots: picocolors: 1.1.1 sax: 1.4.1 + swiper@12.0.1: {} + synckit@0.11.11: dependencies: '@pkgr/core': 0.2.9