diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
| commit | 9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch) | |
| tree | f936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/editor/template-content-editor.tsx | |
| parent | e275618ff8a1ce6977d3e2567d943edb941897f9 (diff) | |
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/editor/template-content-editor.tsx')
| -rw-r--r-- | lib/email-template/editor/template-content-editor.tsx | 609 |
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 |
