summaryrefslogtreecommitdiff
path: root/lib/email-template/editor/template-content-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
commit9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch)
treef936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/editor/template-content-editor.tsx
parente275618ff8a1ce6977d3e2567d943edb941897f9 (diff)
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/editor/template-content-editor.tsx')
-rw-r--r--lib/email-template/editor/template-content-editor.tsx609
1 files changed, 609 insertions, 0 deletions
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<string>("")
+ const [previewSubject, setPreviewSubject] = React.useState<string>("")
+ const [validationErrors, setValidationErrors] = React.useState<string[]>([])
+ 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<string>()
+
+ 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(`<div>${content}</div>`, '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<div style="text-align: center; margin: 20px 0;">
+ <a href="{{buttonUrl}}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ {{buttonText}}
+ </a>
+</div>\n`
+ },
+ {
+ name: '구분선',
+ snippet: `\n<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">\n`
+ },
+ {
+ name: '박스',
+ snippet: `\n<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin: 20px 0;">
+ {{content}}
+</div>\n`
+ },
+ {
+ name: '이미지',
+ snippet: `\n<div style="text-align: center; margin: 20px 0;">
+ <img src="{{imageUrl}}" alt="{{imageAlt}}" style="max-width: 100%; height: auto;">
+</div>\n`
+ },
+ {
+ name: '2열 레이아웃',
+ snippet: `\n<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
+ <tr>
+ <td width="50%" style="padding: 10px;">
+ {{leftContent}}
+ </td>
+ <td width="50%" style="padding: 10px;">
+ {{rightContent}}
+ </td>
+ </tr>
+</table>\n`
+ },
+ {
+ name: '헤더',
+ snippet: `\n<h2 style="color: #333; margin: 20px 0 10px 0; font-size: 24px;">{{title}}</h2>\n`
+ }
+ ]
+
+ return (
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* 왼쪽: 편집 영역 */}
+ <div className="space-y-6">
+ {/* 상태 표시 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {validationErrors.length > 0 && (
+ <Badge variant="destructive" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ {validationErrors.length}개 오류
+ </Badge>
+ )}
+
+ {variableAnalysis.undefinedVars.length > 0 && (
+ <Badge variant="secondary" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ {variableAnalysis.undefinedVars.length}개 미정의 변수
+ </Badge>
+ )}
+
+ <Badge variant="outline">
+ {variableAnalysis.usedVars.length}/{template.variables.length} 변수 사용 중
+ </Badge>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setDarkMode(!darkMode)}
+ title="다크모드 전환"
+ >
+ {darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setAutoPreview(!autoPreview)}
+ className={autoPreview ? 'bg-blue-50' : ''}
+ >
+ <RefreshCw className={`mr-2 h-4 w-4 ${autoPreview ? 'text-blue-600' : ''}`} />
+ 자동 미리보기
+ </Button>
+
+ <Button
+ size="sm"
+ onClick={handleSave}
+ disabled={isLoading || validationErrors.length > 0}
+ >
+ <Save className="mr-2 h-4 w-4" />
+ {isLoading ? '저장 중...' : '저장'}
+ </Button>
+ </div>
+ </div>
+
+ {/* 오류 표시 */}
+ {validationErrors.length > 0 && (
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4">
+ <div className="flex items-start gap-3">
+ <AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
+ <div>
+ <h3 className="font-semibold text-red-800">검증 오류</h3>
+ <ul className="mt-2 text-sm text-red-700 space-y-1">
+ {validationErrors.map((error, index) => (
+ <li key={index}>• {error}</li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 이메일 제목 편집 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Mail className="h-5 w-5" />
+ 이메일 제목
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="subject">제목 템플릿</Label>
+ <Input
+ id="subject"
+ data-subject-input
+ value={subject}
+ onChange={(e) => setSubject(e.target.value)}
+ placeholder="예: {{userName}}님의 {{type}} 알림"
+ className="font-mono"
+ />
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제목에 변수 삽입</Label>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {template.variables.map(variable => (
+ <Badge
+ key={variable.id}
+ variant="outline"
+ className="cursor-pointer hover:bg-blue-50"
+ onClick={() => insertVariable(variable.variableName, 'subject')}
+ >
+ {variable.variableName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 변수 분석 결과 */}
+ {(variableAnalysis.undefinedVars.length > 0 || variableAnalysis.unusedVars.length > 0) && (
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
+ <h3 className="font-semibold text-blue-800 mb-3">변수 사용 분석</h3>
+
+ {variableAnalysis.undefinedVars.length > 0 && (
+ <div className="mb-3">
+ <h4 className="text-sm font-medium text-blue-700 mb-1">미정의 변수</h4>
+ <div className="flex flex-wrap gap-1">
+ {variableAnalysis.undefinedVars.map(varName => (
+ <Badge key={varName} variant="destructive" className="text-xs">
+ {varName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {variableAnalysis.unusedVars.length > 0 && (
+ <div>
+ <h4 className="text-sm font-medium text-blue-700 mb-1">미사용 변수</h4>
+ <div className="flex flex-wrap gap-1">
+ {variableAnalysis.unusedVars.map(varName => (
+ <Badge key={varName} variant="secondary" className="text-xs">
+ {varName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* HTML 스니펫 도구 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Code2 className="h-5 w-5" />
+ 빠른 HTML 삽입
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-2">
+ {htmlSnippets.map((snippet, index) => (
+ <Button
+ key={index}
+ variant="outline"
+ size="sm"
+ onClick={() => insertHtmlSnippet(snippet.snippet)}
+ className="justify-start"
+ >
+ {snippet.name}
+ </Button>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* HtmlCodeEditor로 교체된 HTML 편집기 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Code2 className="h-5 w-5" />
+ HTML 템플릿 편집
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* HtmlCodeEditor 사용 */}
+ <HtmlCodeEditor
+ ref={editorRef}
+ value={content}
+ onChange={setContent}
+ height="600px"
+ darkMode={darkMode}
+ placeholder="HTML 템플릿을 입력하세요..."
+ />
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 변수 삽입 */}
+ <div className="bg-gray-50 p-4 rounded-lg">
+ <h4 className="font-medium text-gray-900 mb-2">변수 삽입</h4>
+ <div className="flex flex-wrap gap-1">
+ {template.variables.map(variable => (
+ <Badge
+ key={variable.id}
+ variant="outline"
+ className="cursor-pointer hover:bg-blue-50"
+ onClick={() => insertVariable(variable.variableName, 'content')}
+ >
+ {variable.variableName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+
+ {/* 도움말 */}
+ <div className="bg-amber-50 p-4 rounded-lg">
+ <h4 className="font-medium text-amber-900 mb-2 flex items-center gap-1">
+ <Lightbulb className="h-4 w-4" />
+ 이메일 HTML 팁
+ </h4>
+ <ul className="text-sm text-amber-800 space-y-1">
+ <li>• 테이블 기반 레이아웃 사용</li>
+ <li>• 인라인 CSS 스타일 권장</li>
+ <li>• max-width: 600px 권장</li>
+ <li>• 이미지에 alt 속성 필수</li>
+ </ul>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 오른쪽: 미리보기 영역 */}
+ <div className="space-y-6">
+ {/* 샘플 데이터 편집 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Settings2 className="h-5 w-5" />
+ 샘플 데이터
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {template.variables.length > 0 ? (
+ <>
+ {template.variables.map(variable => (
+ <div key={variable.id}>
+ <Label className="text-sm">{variable.variableName}</Label>
+ <Input
+ value={sampleData[variable.variableName] || ''}
+ onChange={(e) => updateSampleData(variable.variableName, e.target.value)}
+ placeholder={variable.description || `${variable.variableName} 값 입력`}
+ className="text-sm"
+ />
+ </div>
+ ))}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handlePreview(false)}
+ disabled={isPreviewLoading}
+ className="w-full mt-4"
+ >
+ <RefreshCw className={`mr-2 h-4 w-4 ${isPreviewLoading ? 'animate-spin' : ''}`} />
+ 미리보기 새로고침
+ </Button>
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>변수가 없습니다.</p>
+ <p className="text-sm">변수 관리 탭에서 변수를 추가하세요.</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 미리보기 결과 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>미리보기</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 제목 미리보기 */}
+ {previewSubject && (
+ <div className="p-3 bg-blue-50 rounded-lg">
+ <Label className="text-sm font-medium text-blue-900">제목:</Label>
+ <p className="font-semibold text-blue-900 break-words">{previewSubject}</p>
+ </div>
+ )}
+
+ {/* 내용 미리보기 */}
+ {previewHtml ? (
+ <div className="border rounded-lg bg-white">
+ <iframe
+ srcDoc={previewHtml}
+ sandbox="allow-same-origin"
+ className="w-full h-96 border-0 rounded-lg"
+ title="Template Preview"
+ />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
+ <div className="text-center">
+ <Mail className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">템플릿을 편집하면 미리보기가 표시됩니다</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handlePreview(false)}
+ className="mt-2"
+ >
+ 미리보기 생성
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ )
+} \ No newline at end of file