From 9da494b0e3bbe7b513521d0915510fe9ee376b8b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:19:52 +0000 Subject: (대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/template-content-editor.tsx | 609 +++++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 lib/email-template/editor/template-content-editor.tsx (limited to 'lib/email-template/editor/template-content-editor.tsx') diff --git a/lib/email-template/editor/template-content-editor.tsx b/lib/email-template/editor/template-content-editor.tsx new file mode 100644 index 00000000..4d31753c --- /dev/null +++ b/lib/email-template/editor/template-content-editor.tsx @@ -0,0 +1,609 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { AlertTriangle, Save, Mail, RefreshCw, Settings2, Code2, Lightbulb, Moon, Sun } from "lucide-react" +import { toast } from "sonner" +import { type TemplateWithVariables } from "@/db/schema" +import { previewTemplateAction, updateTemplateAction } from "../service" +import { useSession } from "next-auth/react" +import { HtmlCodeEditor } from "@/components/template-editor/html-code-editor" +import { formatHtml } from "@/lib/utils" + +interface TemplateContentEditorProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +export function TemplateContentEditor({ template, onUpdate }: TemplateContentEditorProps) { + const [content, setContent] = React.useState(() => { + return formatHtml(template.content) + }) + const [subject, setSubject] = React.useState(template.subject || '') + const [isLoading, setIsLoading] = React.useState(false) + const [isPreviewLoading, setIsPreviewLoading] = React.useState(false) + const [previewHtml, setPreviewHtml] = React.useState("") + const [previewSubject, setPreviewSubject] = React.useState("") + const [validationErrors, setValidationErrors] = React.useState([]) + const [sampleData, setSampleData] = React.useState(template.sampleData || {}) + const [autoPreview, setAutoPreview] = React.useState(true) + const [darkMode, setDarkMode] = React.useState(false) // 다크모드 상태 추가 + const { data: session } = useSession(); + + React.useEffect(() => { + if (template.content !== content) { + const formatted = formatHtml(template.content) + setContent(formatted) + } + }, [template.content]) + + // HtmlCodeEditor ref + const editorRef = React.useRef<{ + focus: () => void + insertText: (text: string) => void + getEditor: () => any + }>(null) + + React.useEffect(() => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다"); + } + }, [session]); + + // 자동 미리보기 (디바운스) - 시간 늘림 + React.useEffect(() => { + if (!autoPreview) return + + const timer = setTimeout(() => { + handlePreview(true) + }, 1500) // 1.5초로 증가 + + return () => clearTimeout(timer) + }, [content, subject, sampleData, autoPreview]) + + // 실시간 검증 + React.useEffect(() => { + validateContent(content, subject) + }, [content, subject]) + + // 변수 사용량 분석 + const analyzeVariableUsage = React.useCallback(() => { + const templateVars = template.variables.map(v => v.variableName) + const usedVars = new Set() + + const combinedText = content + ' ' + subject + const matches = combinedText.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g) + if (matches) { + matches.forEach(match => { + const varName = match.replace(/\{\{\s*|\s*\}\}/g, '') + usedVars.add(varName) + }) + } + + const unusedVars = templateVars.filter(v => !usedVars.has(v)) + const undefinedVars = Array.from(usedVars).filter(v => + !templateVars.includes(v) && + !['t', 'siteName', 'companyName', 'i18n'].includes(v) + ) + + return { unusedVars, undefinedVars, usedVars: Array.from(usedVars) } + }, [content, subject, template.variables]) + + // 검증 함수 + const validateContent = (content: string, subject: string) => { + const errors: string[] = [] + + if (subject.length > 200) { + errors.push('이메일 제목이 너무 깁니다 (200자 초과)') + } + + const forbiddenChars = ['\n', '\r', '\t'] + forbiddenChars.forEach(char => { + if (subject.includes(char)) { + errors.push('이메일 제목에 개행 문자나 탭 문자를 사용할 수 없습니다') + } + }) + + // HTML 기본 검증 추가 + if (content.trim()) { + try { + const parser = new DOMParser() + const doc = parser.parseFromString(`
${content}
`, 'text/html') + + const parseError = doc.querySelector('parsererror') + if (parseError) { + errors.push('유효하지 않은 HTML 구조가 감지되었습니다.') + } + } catch (error) { + errors.push('HTML 구문 분석 중 오류가 발생했습니다.') + } + } + + const dangerousPatterns = [ + /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, + /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, + ] + + const combinedText = content + ' ' + subject + for (const pattern of dangerousPatterns) { + if (pattern.test(combinedText)) { + errors.push('보안상 위험한 구문이 감지되었습니다.') + break + } + } + + setValidationErrors(errors) + } + + // 저장 함수 + const handleSave = async () => { + if (validationErrors.length > 0) { + toast.error('검증 오류를 먼저 해결해주세요.') + return + } + + setIsLoading(true) + try { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const result = await updateTemplateAction(template.slug, { + subject, + content, + sampleData, + updatedBy: Number(session.user.id) + }) + + if (result.success) { + toast.success('템플릿이 저장되었습니다.') + onUpdate({ + ...template, + subject, + content, + sampleData, + version: template.version + 1 + }) + } else { + toast.error(result.error || '저장에 실패했습니다.') + } + } catch (error) { + toast.error('저장 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 미리보기 생성 + const handlePreview = async (silent = false) => { + if (!silent) setIsPreviewLoading(true) + + try { + const result = await previewTemplateAction( + template.slug, + sampleData, + content, + // subject // 주석 해제 + ) + + if (result.success) { + setPreviewHtml(result.data.html) + setPreviewSubject(result.data.subject) + if (!silent) toast.success('미리보기가 생성되었습니다.') + } else { + if (!silent) toast.error(result.error || '미리보기 생성에 실패했습니다.') + } + } catch (error) { + if (!silent) toast.error('미리보기 생성 중 오류가 발생했습니다.') + } finally { + if (!silent) setIsPreviewLoading(false) + } + } + + // 변수 삽입 함수 - HtmlCodeEditor 지원 + const insertVariable = (variableName: string, targetField: 'subject' | 'content') => { + const variable = `{{${variableName}}}` + + if (targetField === 'subject') { + const input = document.querySelector('input[data-subject-input]') as HTMLInputElement + if (input) { + const start = input.selectionStart || 0 + const end = input.selectionEnd || 0 + const newValue = subject.substring(0, start) + variable + subject.substring(end) + setSubject(newValue) + + setTimeout(() => { + input.focus() + input.setSelectionRange(start + variable.length, start + variable.length) + }, 0) + } + } else { + // HtmlCodeEditor에 변수 삽입 + if (editorRef.current) { + editorRef.current.insertText(variable) + } + } + } + + // HTML 템플릿 스니펫 삽입 - HtmlCodeEditor 지원 + const insertHtmlSnippet = (snippet: string) => { + if (editorRef.current) { + editorRef.current.insertText(snippet) + } + } + + // 샘플 데이터 업데이트 + const updateSampleData = (key: string, value: any) => { + const newSampleData = { ...sampleData, [key]: value } + setSampleData(newSampleData) + } + + const variableAnalysis = analyzeVariableUsage() + + // HTML 스니펫들 - 개행 추가로 더 깔끔하게 + const htmlSnippets = [ + { + name: '버튼', + snippet: `\n\n` + }, + { + name: '구분선', + snippet: `\n
\n` + }, + { + name: '박스', + snippet: `\n
+ {{content}} +
\n` + }, + { + name: '이미지', + snippet: `\n
+ {{imageAlt}} +
\n` + }, + { + name: '2열 레이아웃', + snippet: `\n + + + + +
+ {{leftContent}} + + {{rightContent}} +
\n` + }, + { + name: '헤더', + snippet: `\n

{{title}}

\n` + } + ] + + return ( +
+ {/* 왼쪽: 편집 영역 */} +
+ {/* 상태 표시 */} +
+
+ {validationErrors.length > 0 && ( + + + {validationErrors.length}개 오류 + + )} + + {variableAnalysis.undefinedVars.length > 0 && ( + + + {variableAnalysis.undefinedVars.length}개 미정의 변수 + + )} + + + {variableAnalysis.usedVars.length}/{template.variables.length} 변수 사용 중 + +
+ +
+ + + + + +
+
+ + {/* 오류 표시 */} + {validationErrors.length > 0 && ( +
+
+ +
+

검증 오류

+
    + {validationErrors.map((error, index) => ( +
  • • {error}
  • + ))} +
+
+
+
+ )} + + {/* 이메일 제목 편집 */} + + + + + 이메일 제목 + + + +
+ + setSubject(e.target.value)} + placeholder="예: {{userName}}님의 {{type}} 알림" + className="font-mono" + /> +
+ +
+ +
+ {template.variables.map(variable => ( + insertVariable(variable.variableName, 'subject')} + > + {variable.variableName} + + ))} +
+
+
+
+ + {/* 변수 분석 결과 */} + {(variableAnalysis.undefinedVars.length > 0 || variableAnalysis.unusedVars.length > 0) && ( +
+

변수 사용 분석

+ + {variableAnalysis.undefinedVars.length > 0 && ( +
+

미정의 변수

+
+ {variableAnalysis.undefinedVars.map(varName => ( + + {varName} + + ))} +
+
+ )} + + {variableAnalysis.unusedVars.length > 0 && ( +
+

미사용 변수

+
+ {variableAnalysis.unusedVars.map(varName => ( + + {varName} + + ))} +
+
+ )} +
+ )} + + {/* HTML 스니펫 도구 */} + + + + + 빠른 HTML 삽입 + + + +
+ {htmlSnippets.map((snippet, index) => ( + + ))} +
+
+
+ + {/* HtmlCodeEditor로 교체된 HTML 편집기 */} + + + + + HTML 템플릿 편집 + + + + {/* HtmlCodeEditor 사용 */} + + +
+ {/* 변수 삽입 */} +
+

변수 삽입

+
+ {template.variables.map(variable => ( + insertVariable(variable.variableName, 'content')} + > + {variable.variableName} + + ))} +
+
+ + {/* 도움말 */} +
+

+ + 이메일 HTML 팁 +

+
    +
  • • 테이블 기반 레이아웃 사용
  • +
  • • 인라인 CSS 스타일 권장
  • +
  • • max-width: 600px 권장
  • +
  • • 이미지에 alt 속성 필수
  • +
+
+
+
+
+
+ + {/* 오른쪽: 미리보기 영역 */} +
+ {/* 샘플 데이터 편집 */} + + + + + 샘플 데이터 + + + + {template.variables.length > 0 ? ( + <> + {template.variables.map(variable => ( +
+ + updateSampleData(variable.variableName, e.target.value)} + placeholder={variable.description || `${variable.variableName} 값 입력`} + className="text-sm" + /> +
+ ))} + + + + ) : ( +
+

변수가 없습니다.

+

변수 관리 탭에서 변수를 추가하세요.

+
+ )} +
+
+ + {/* 미리보기 결과 */} + + + 미리보기 + + + {/* 제목 미리보기 */} + {previewSubject && ( +
+ +

{previewSubject}

+
+ )} + + {/* 내용 미리보기 */} + {previewHtml ? ( +
+