feat: 커스텀 수정 중

This commit is contained in:
“hyeonggkim”
2026-01-21 18:39:54 +09:00
parent 927f7ace3b
commit ca4d9698f6
2 changed files with 564 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import FxDownload01 from '#layers/templates/FxDownload01/index.vue'
import FxCoupon01 from '#layers/templates/FxCoupon01/index.vue'
import FxSecure01 from '#layers/templates/FxSecure01/index.vue'
import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue'
import CtLayout01 from '#layers/templates/CtLayout01/index.vue'
const templateRegistry = {
GR_VISUAL_01: { component: GrVisual01 },
@@ -32,6 +33,7 @@ const templateRegistry = {
FX_COUPON_01: { component: FxCoupon01 },
FX_SECURE_01: { component: FxSecure01 },
FX_PREREGIST_01: { component: FxPreregist01 },
CT_LAYOUT_01: { component: CtLayout01 },
} as const
type TemplateKey = keyof typeof templateRegistry

View File

@@ -0,0 +1,562 @@
<script setup lang="ts">
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
interface Props {
components: PageDataTemplateComponents
pageVerTmplSeq: number
}
const props = defineProps<Props>()
const { sendLog } = useAnalytics()
const { tm, locale } = useI18n()
const customContainerRef = ref<HTMLElement | null>(null)
const nuxtApp = useNuxtApp()
// Configuration
const runtimeConfig = useRuntimeConfig()
const staticUrl = runtimeConfig.public.staticUrl
const coerceToString = (value: unknown): string => {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
return ''
}
// URL인지 확인하는 함수
const isUrl = (str: string): boolean => {
try {
const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
// API로 내려온 "HTML 문자열" 내부의 {{ tm('key') }} 형태를 현재 locale 기준 번역 문자열로 치환
// (Vue 템플릿 컴파일이 일어나지 않으므로 사전 치환이 필요)
const applyI18nToApiHtml = (html: string): string => {
// locale 변경 시 computed가 재평가되도록 의존성 연결
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
locale.value
return html.replace(
/\{\{\s*tm\(\s*(["'])([^"']+)\1\s*\)\s*\}\}/g,
(_full, _q, key: string) => coerceToString(tm(key)),
)
}
// fetch한 HTML을 저장할 ref
const fetchedHtml = ref<string>('')
const isLoadingHtml = ref(false)
// URL에서 HTML 가져오기
const fetchHtmlFromUrl = async (url: string): Promise<string> => {
try {
isLoadingHtml.value = true
const response = await $fetch<string>(url, {
method: 'GET',
headers: {
'Accept': 'text/html',
},
})
return typeof response === 'string' ? response : JSON.stringify(response)
} catch (error: any) {
// eslint-disable-next-line no-console
console.error('[Exception] Failed to fetch custom HTML:', error)
return ''
} finally {
isLoadingHtml.value = false
}
}
// HTML 주석에서 customTranslang JSON URL 추출
// 형식: <!-- customTranslang: https://example.com/translations.json -->
const extractCustomTranslangUrl = (html: string): string | null => {
const commentRegex = /<!--\s*customTranslang\s*:\s*([^\s]+)\s*-->/i
const match = html.match(commentRegex)
return match && match[1] ? match[1].trim() : null
}
// JSON 파일을 fetch해서 i18n에 추가
const loadCustomTranslations = async (jsonUrl: string): Promise<void> => {
try {
const $i18n = nuxtApp.$i18n as any
if (!$i18n) {
// eslint-disable-next-line no-console
console.warn('[CustomTranslations] i18n instance not found')
return
}
const translations = await $fetch<Record<string, any>>(jsonUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
if (!translations || typeof translations !== 'object') {
// eslint-disable-next-line no-console
console.warn('[CustomTranslations] Invalid translation data:', translations)
return
}
// 로케일 코드 변환 맵 (소문자 하이픈 -> 대문자 하이픈)
const localeMap: Record<string, string> = {
'zh-tw': 'zh-TW',
'zh-cn': 'zh-CN',
}
// 현재 locale에 해당하는 번역 데이터 찾기
const currentLocale = locale.value
const normalizedLocale = localeMap[currentLocale] || currentLocale
// 대소문자 구분 없이 로케일 데이터 찾기
const localeData =
translations[normalizedLocale] ||
translations[normalizedLocale.toLowerCase()] ||
translations[currentLocale] ||
translations[currentLocale.toLowerCase()]
if (localeData && typeof localeData === 'object') {
// 기존 메시지에 병합 (기존 키는 유지하고 새로운 키만 추가)
const existingMessages = $i18n.getLocaleMessage(currentLocale) || {}
$i18n.setLocaleMessage(currentLocale, {
...existingMessages,
...localeData,
})
// eslint-disable-next-line no-console
console.log('[CustomTranslations] Loaded translations for locale:', currentLocale)
}
} catch (error: any) {
// eslint-disable-next-line no-console
console.error('[Exception] Failed to load custom translations:', error)
}
}
// custom_contents가 URL인지 확인하고 HTML 가져오기
watch(
() => {
// 타입 가드: 단일 컴포넌트 패턴인지 확인
if ('group_sets' in props.components) {
return null
}
const customContents = props.components.customContents
const firstGroup = customContents?.groups?.[0]
if (!firstGroup) {
return null
}
// res_path는 PageDataResourceGroup의 직접 속성
// 또는 btn_info.detail.res_path 형태일 수 있음
const resPath = (firstGroup as any).detail?.res_path
if (!resPath) {
return null
}
// 문자열로 변환
const resPathStr = String(resPath)
// 마지막에 "/contents" 또는 "contents"가 있으면 제거
const cleanedResPath = resPathStr.replace(/\/?contents\/?$/, '')
return cleanedResPath
},
async (cleanedResPath) => {
if (!cleanedResPath) {
fetchedHtml.value = ''
return
}
const contentStr = typeof cleanedResPath === 'string' ? `${staticUrl}${cleanedResPath}/index.html` : String(`${staticUrl}${cleanedResPath}/index.html`)
console.log("🚀 ~ contentStr:", contentStr)
let html = ''
if (isUrl(contentStr)) {
// URL인 경우 fetch
html = await fetchHtmlFromUrl(contentStr)
} else {
// HTML 문자열인 경우 그대로 사용
html = contentStr
}
fetchedHtml.value = html
// HTML 주석에서 customTranslang JSON URL 추출 및 로드
if (html) {
const jsonUrl = extractCustomTranslangUrl(html)
if (jsonUrl) {
await loadCustomTranslations(jsonUrl)
}
}
},
{ immediate: true },
)
// locale 변경 시에도 custom translations 다시 로드
watch(
() => locale.value,
async () => {
if (fetchedHtml.value) {
const jsonUrl = extractCustomTranslangUrl(fetchedHtml.value)
if (jsonUrl) {
await loadCustomTranslations(jsonUrl)
}
}
},
)
// API HTML 원본 (i18n만 치환) - asset(link/style/script) 추출용
const customContentsRaw = computed(() => {
if (!fetchedHtml.value) return ''
let html = fetchedHtml.value
// i18n 치환 ({{ tm('...') }})
html = applyI18nToApiHtml(html)
return html
})
// 렌더링용 HTML
// - link/script는 head/body로 별도 주입 (렌더 위치에 남기지 않음)
// - style은 "그 자리에 그대로" 렌더링하되, 적용 안정성을 위해 head에도 복제 주입함
const customContentsHtml = computed(() => {
const html = customContentsRaw.value
if (!html) return ''
return html
// link 태그 제거
.replace(/<link\b[^>]*>/gi, '')
// script 블록 제거
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
})
type OrderedAsset =
| { kind: 'link'; href: string; rel?: string | null; media?: string | null; integrity?: string | null; crossOrigin?: string | null; referrerPolicy?: string | null }
| { kind: 'script'; src: string; type?: string | null; integrity?: string | null; crossOrigin?: string | null; referrerPolicy?: string | null }
| { kind: 'style'; cssText: string }
const parseTagAttributes = (tagHtml: string): Record<string, string> => {
const attrs: Record<string, string> = {}
const attrRegex = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g
let m: RegExpExecArray | null
while ((m = attrRegex.exec(tagHtml)) !== null) {
const key = m[1]?.toLowerCase()
const value = (m[2] ?? m[3] ?? m[4] ?? '').trim()
if (key) attrs[key] = value
}
return attrs
}
// asset(link/script) 주입 위치: main 우선, 없으면 body fallback
const getAssetMountEl = (): HTMLElement => {
if (!import.meta.client) {
// SSR에서는 호출되지 않지만 타입 안정성을 위해
return {} as HTMLElement
}
return (document.querySelector('main') as HTMLElement | null) ?? document.body
}
// custom_contents 원본 문자열에서 link/script를 "작성된 순서" 그대로 추출 (파서가 위치를 바꾸는 문제 방지)
const extractOrderedAssets = (html: string): OrderedAsset[] => {
const assets: OrderedAsset[] = []
// link/script 시작 태그 + style 블록 전체를 한 번에 매칭 (작성된 순서 보존)
const tagRegex = /<(link|script)\b[^>]*>|<style\b[^>]*>[\s\S]*?<\/style>/gi
let match: RegExpExecArray | null
while ((match = tagRegex.exec(html)) !== null) {
const tagHtml = match[0] || ''
const tagName = (match[1] || '').toLowerCase()
// style 블록
if (/^<style\b/i.test(tagHtml)) {
const cssText = tagHtml
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style>\s*$/i, '')
if (cssText.trim()) {
assets.push({ kind: 'style', cssText })
}
continue
}
const attrs = parseTagAttributes(tagHtml)
if (tagName === 'link') {
const href = attrs.href
if (!href) continue
assets.push({
kind: 'link',
href,
rel: attrs.rel ?? null,
media: attrs.media ?? null,
integrity: attrs.integrity ?? null,
crossOrigin: attrs.crossorigin ?? null,
referrerPolicy: attrs.referrerpolicy ?? null,
})
continue
}
if (tagName === 'script') {
const src = attrs.src
if (!src) continue
assets.push({
kind: 'script',
src,
type: attrs.type ?? null,
integrity: attrs.integrity ?? null,
crossOrigin: attrs.crossorigin ?? null,
referrerPolicy: attrs.referrerpolicy ?? null,
})
}
}
return assets
}
// CSS 파일 동적 로드
const loadStylesheet = (asset: Extract<OrderedAsset, { kind: 'link' }>): Promise<void> => {
return new Promise((resolve, reject) => {
if (!import.meta.client) {
resolve()
return
}
const href = asset.href
// 이미 로드된 스타일시트 확인
const existingLink = document.querySelector(`link[href="${href}"]`)
if (existingLink) {
resolve()
return
}
const link = document.createElement('link')
link.rel = asset.rel || 'stylesheet'
link.href = href
if (asset.media) link.media = asset.media
if (asset.integrity) link.integrity = asset.integrity
if (asset.crossOrigin) link.crossOrigin = asset.crossOrigin
if (asset.referrerPolicy) link.referrerPolicy = asset.referrerPolicy
link.onload = () => resolve()
link.onerror = () => reject(new Error(`Failed to load stylesheet: ${href}`))
getAssetMountEl().appendChild(link)
})
}
// JS 파일 동적 로드
const loadScript = (asset: Extract<OrderedAsset, { kind: 'script' }>): Promise<void> => {
return new Promise((resolve, reject) => {
if (!import.meta.client) {
resolve()
return
}
const src = asset.src
// 이미 로드된 스크립트 확인
const existingScript = document.querySelector(`script[src="${src}"]`)
if (existingScript) {
resolve()
return
}
const script = document.createElement('script')
script.src = src
// 순서 보장을 위해 async=false
script.async = false
if (asset.type) script.type = asset.type
if (asset.integrity) script.integrity = asset.integrity
if (asset.crossOrigin) script.crossOrigin = asset.crossOrigin
if (asset.referrerPolicy) script.referrerPolicy = asset.referrerPolicy
script.onload = () => resolve()
script.onerror = () => reject(new Error(`Failed to load script: ${src}`))
getAssetMountEl().appendChild(script)
})
}
// 문자열 해시(간단) - style 중복 주입 방지용
const hashString = (str: string): string => {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i)
}
// unsigned 32-bit
return (hash >>> 0).toString(16)
}
// inline <style> 주입 (작성 순서 보존 + 중복 방지)
const loadInlineStyle = (asset: Extract<OrderedAsset, { kind: 'style' }>): void => {
if (!import.meta.client) return
const cssText = asset.cssText || ''
const key = hashString(cssText)
const existing = document.head.querySelector(`style[data-custom-style-hash="${key}"]`)
if (existing) return
const styleEl = document.createElement('style')
styleEl.setAttribute('data-custom-style-hash', key)
styleEl.textContent = cssText
getAssetMountEl().appendChild(styleEl)
}
// 스크립트 로드 완료 여부 추적
const scriptsLoaded = ref(false)
const modalStore = useModalStore()
// API HTML에서 onclick으로 호출할 수 있도록 window에 노출할 "단일 함수"
// - 인자 방식: onclick="window.__customAction('open-confirm', '내용', '확인', '취소', '로그값')"
// - dataset 방식도 호환: onclick="window.__customAction(this)"
const handleCustomAction = (
actionOrEl: string | HTMLElement | null,
rawContent?: string,
confirmText?: string,
cancelText?: string,
logValue?: string,
) => {
if (!import.meta.client) return
if (!actionOrEl) return
const action =
typeof actionOrEl === 'string'
? actionOrEl
: actionOrEl.getAttribute('data-action') || ''
const contentRaw =
typeof actionOrEl === 'string'
? rawContent || ''
: actionOrEl.getAttribute('data-content') ||
actionOrEl.getAttribute('data-confirm-content') ||
''
const confirmButtonText =
typeof actionOrEl === 'string'
? confirmText || ''
: actionOrEl.getAttribute('data-confirm-text') || ''
const cancelButtonText =
typeof actionOrEl === 'string'
? cancelText || ''
: actionOrEl.getAttribute('data-cancel-text') || ''
const analyticsLogValue =
typeof actionOrEl === 'string'
? logValue || ''
: actionOrEl.getAttribute('data-log') || ''
// 1) open-confirm
if (action === 'open-confirm') {
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
if (contentText) {
modalStore.handleOpenConfirm({
contentText,
...(confirmButtonText ? { confirmButtonText } : {}),
...(cancelButtonText ? { cancelButtonText } : {}),
})
}
}
if (action === 'open-alert') {
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
if (contentText) {
modalStore.handleOpenAlert({
contentText,
...(confirmButtonText ? { confirmButtonText } : {}),
...(cancelButtonText ? { cancelButtonText } : {}),
})
}
}
// 2) analytics
if (analyticsLogValue) {
console.log("🚀 ~ handleCustomAction ~ analyticsLogValue:", analyticsLogValue)
sendLog(locale.value, analyticsLogValue)
}
}
// custom_contents 변경 시 link/script를 "작성된 순서"대로 순차 로드
watch(
() => customContentsRaw.value,
async (html, _prev, onCleanup) => {
if (!import.meta.client) return
if (!html) return
scriptsLoaded.value = false
let cancelled = false
onCleanup(() => {
cancelled = true
})
const assets = extractOrderedAssets(html)
for (const asset of assets) {
if (cancelled) return
try {
if (asset.kind === 'link') {
await loadStylesheet(asset)
} else if (asset.kind === 'style') {
loadInlineStyle(asset)
} else {
await loadScript(asset)
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`${asset.kind} load error:`, err)
}
}
if (!cancelled) scriptsLoaded.value = true
},
{ immediate: true },
)
// onMounted에서 초기화 코드 실행
onMounted(() => {
if (!import.meta.client) return
const container = customContainerRef.value
if (!container) return
// onclick="window.__customAction(this)" 지원
;(window as any).__customAction = handleCustomAction
// onclick을 못 쓰는 케이스 대비: 이벤트 위임도 같이 지원
const onDelegatedClick = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
const el =
(target?.closest?.('[data-action], [data-log]') as HTMLElement | null) ??
null
handleCustomAction(el)
}
container.addEventListener('click', onDelegatedClick)
onBeforeUnmount(() => {
container.removeEventListener('click', onDelegatedClick)
})
})
onBeforeUnmount(() => {
if (!import.meta.client) return
const w = window as any
if (w.__customAction === handleCustomAction) {
delete w.__customAction
}
})
</script>
<template>
<section class="section-standard">
<div
v-if="customContentsHtml"
ref="customContainerRef"
v-dompurify-admin="customContentsHtml"
class="custom-container"
></div>
<div
class="custom-container"
>
{{ tm('Newreturnevent_Section03_Title') }}
</div>
</section>
</template>
<style>
.custom-container {
min-height: 20rem;
background-color: #fff;
}
</style>