diff options
Diffstat (limited to 'lib/email-template/editor')
| -rw-r--r-- | lib/email-template/editor/template-content-editor.tsx | 609 | ||||
| -rw-r--r-- | lib/email-template/editor/template-editor.tsx | 175 | ||||
| -rw-r--r-- | lib/email-template/editor/template-preview.tsx | 406 | ||||
| -rw-r--r-- | lib/email-template/editor/template-settings.tsx | 474 | ||||
| -rw-r--r-- | lib/email-template/editor/template-variable-manager.tsx | 562 |
5 files changed, 2226 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 diff --git a/lib/email-template/editor/template-editor.tsx b/lib/email-template/editor/template-editor.tsx new file mode 100644 index 00000000..68cade45 --- /dev/null +++ b/lib/email-template/editor/template-editor.tsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft, Save, Edit, Settings, List } from "lucide-react" +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" + +import { type TemplateWithVariables } from "@/db/schema" +import { TemplateContentEditor } from "./template-content-editor" +import { TemplateVariableManager } from "./template-variable-manager" +import { TemplateSettings } from "./template-settings" + +interface TemplateEditorProps { + templateSlug: string + initialTemplate: TemplateWithVariables +} + +export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditorProps) { + const router = useRouter() + const [template, setTemplate] = React.useState(initialTemplate) + + return ( + <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8"> + {/* 헤더 */} + <div className="flex items-center gap-4"> + <Button variant="ghost" size="icon" asChild> + <Link href="/evcp/email-template"> + <ArrowLeft className="h-4 w-4" /> + </Link> + </Button> + <div className="flex-1"> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-semibold">{template.name}</h1> + <Badge variant="outline" className="text-xs"> + v{template.version} + </Badge> + {template.category && ( + <Badge variant="secondary" className="text-xs"> + {template.category} + </Badge> + )} + </div> + <p className="text-sm text-muted-foreground"> + {template.description || "템플릿 편집"} + </p> + </div> + + {/* 헤더 액션 버튼들 */} + <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/evcp/templates/${template.slug}/send`}> + 테스트 발송 + </Link> + </Button> + </div> + </div> + + <Separator /> + + {/* 메인 편집 영역 - 3개 탭으로 간소화 */} + <Tabs defaultValue="editor" className="flex-1"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger value="editor" className="gap-2"> + <Edit className="h-4 w-4" /> + 편집 & 미리보기 + </TabsTrigger> + <TabsTrigger value="variables" className="gap-2"> + <List className="h-4 w-4" /> + 변수 관리 + </TabsTrigger> + <TabsTrigger value="settings" className="gap-2"> + <Settings className="h-4 w-4" /> + 설정 + </TabsTrigger> + </TabsList> + + {/* 편집 & 미리보기 탭 (통합) */} + <TabsContent value="editor" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>템플릿 편집 & 미리보기</CardTitle> + <CardDescription> + 왼쪽에서 이메일 제목과 내용을 편집하고, 오른쪽에서 실시간 미리보기를 확인하세요. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateContentEditor + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + + {/* 변수 관리 탭 */} + <TabsContent value="variables" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>변수 관리</CardTitle> + <CardDescription> + 템플릿에서 사용할 변수를 추가하고 관리하세요. 추가되는 변수는 실제 코드에서 정의가 되어있지 않으면 템플릿에 있더라도 나타나지 않습니다. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateVariableManager + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + + {/* 설정 탭 */} + <TabsContent value="settings" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>템플릿 설정</CardTitle> + <CardDescription> + 템플릿의 기본 정보와 설정을 관리하세요. + </CardDescription> + </CardHeader> + <CardContent> + <TemplateSettings + template={template} + onUpdate={setTemplate} + /> + </CardContent> + </Card> + </TabsContent> + </Tabs> + + {/* 추가 정보 */} + <div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-muted-foreground"> + <div className="bg-blue-50 p-4 rounded-lg"> + <h4 className="font-medium text-blue-900 mb-2">💡 편집 팁</h4> + <ul className="space-y-1 text-blue-800"> + <li>• 자동 미리보기를 켜두면 편집하면서 바로 확인 가능</li> + <li>• 변수 배지를 클릭하면 커서 위치에 바로 삽입</li> + <li>• Ctrl+S로 빠른 저장</li> + </ul> + </div> + + <div className="bg-green-50 p-4 rounded-lg"> + <h4 className="font-medium text-green-900 mb-2">📊 템플릿 상태</h4> + <ul className="space-y-1 text-green-800"> + <li>• 변수: {template.variables.length}개</li> + <li>• 필수 변수: {template.variables.filter(v => v.isRequired).length}개</li> + <li>• 최종 수정: {new Date(template.updatedAt).toLocaleDateString('ko-KR')}</li> + </ul> + </div> + + <div className="bg-purple-50 p-4 rounded-lg"> + <h4 className="font-medium text-purple-900 mb-2">🚀 다음 단계</h4> + <ul className="space-y-1 text-purple-800"> + <li>• 변수 관리에서 필요한 변수 추가</li> + <li>• 설정에서 카테고리 및 설명 수정</li> + <li>• 테스트 발송으로 실제 이메일 확인</li> + </ul> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-preview.tsx b/lib/email-template/editor/template-preview.tsx new file mode 100644 index 00000000..df6b8461 --- /dev/null +++ b/lib/email-template/editor/template-preview.tsx @@ -0,0 +1,406 @@ +"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 { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { + Eye, + Smartphone, + Monitor, + Tablet, + RefreshCw, + Copy, + Download, + Send +} from "lucide-react" +import { toast } from "sonner" +import { type TemplateWithVariables } from "@/db/schema" +import { previewTemplateAction } from "../service" +import { copyTextToClipboard } from "@/lib/utils" + +interface TemplatePreviewProps { + template: TemplateWithVariables +} + +type ViewMode = 'desktop' | 'tablet' | 'mobile' + +export function TemplatePreview({ template }: TemplatePreviewProps) { + const [previewData, setPreviewData] = React.useState<Record<string, any>>( + template.sampleData || {} + ) + const [previewHtml, setPreviewHtml] = React.useState<string>("") + const [isLoading, setIsLoading] = React.useState(false) + const [viewMode, setViewMode] = React.useState<ViewMode>('desktop') + const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null) + + // 미리보기 생성 + const generatePreview = async () => { + setIsLoading(true) + try { + const result = await previewTemplateAction( + template.slug, + previewData, + template.content + ) + + if (result.success) { + setPreviewHtml(result.data.html) + setLastUpdated(new Date()) + toast.success('미리보기가 생성되었습니다.') + } else { + toast.error("샘플데이터를 먼저 생성해주세요." || '미리보기 생성에 실패했습니다.') + } + } catch (error) { + toast.error('미리보기 생성 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 초기 미리보기 생성 + React.useEffect(() => { + generatePreview() + }, []) + + // 샘플 데이터 업데이트 + const updatePreviewData = (key: string, value: any) => { + setPreviewData(prev => ({ ...prev, [key]: value })) + } + + // 샘플 데이터 리셋 + const resetToDefaults = () => { + const defaultData: Record<string, any> = {} + + template.variables.forEach(variable => { + if (variable.defaultValue) { + switch (variable.variableType) { + case 'number': + defaultData[variable.variableName] = parseFloat(variable.defaultValue) || 0 + break + case 'boolean': + defaultData[variable.variableName] = variable.defaultValue.toLowerCase() === 'true' + break + default: + defaultData[variable.variableName] = variable.defaultValue + } + } else { + // 기본 샘플 값 + switch (variable.variableType) { + case 'string': + defaultData[variable.variableName] = `샘플 ${variable.variableName}` + break + case 'number': + defaultData[variable.variableName] = 123 + break + case 'boolean': + defaultData[variable.variableName] = true + break + case 'date': + defaultData[variable.variableName] = new Date().toLocaleDateString('ko-KR') + break + } + } + }) + + setPreviewData(defaultData) + toast.success('샘플 데이터가 초기화되었습니다.') + } + + // HTML 복사 + const copyHtml = async () => { + if (!previewHtml) { + toast.error("미리보기를 먼저 생성해주세요.") + return + } + + const ok = await copyTextToClipboard(previewHtml) + ok + ? toast.success("HTML이 클립보드에 복사되었습니다.") + : toast.error("복사에 실패했습니다.") + } + // HTML 다운로드 + const downloadHtml = () => { + if (!previewHtml) { + toast.error('미리보기를 먼저 생성해주세요.') + return + } + + const blob = new Blob([previewHtml], { type: 'text/html' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${template.slug}-preview.html` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + + toast.success('HTML 파일이 다운로드되었습니다.') + } + + // 뷰포트 크기 + const getViewportStyle = (): React.CSSProperties => { + switch (viewMode) { + case 'mobile': + return { width: '375px', height: '667px' } + case 'tablet': + return { width: '768px', height: '1024px' } + case 'desktop': + default: + return { width: '100%', height: '600px' } + } + } + + // 입력 컴포넌트 렌더링 + const renderInputForVariable = (variable: any) => { + const value = previewData[variable.variableName] || '' + + switch (variable.variableType) { + case 'boolean': + return ( + <Select + value={String(value)} + onValueChange={(newValue) => updatePreviewData(variable.variableName, newValue === 'true')} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="true">True</SelectItem> + <SelectItem value="false">False</SelectItem> + </SelectContent> + </Select> + ) + + case 'number': + return ( + <Input + type="number" + value={value} + onChange={(e) => updatePreviewData(variable.variableName, parseFloat(e.target.value) || 0)} + placeholder="숫자를 입력하세요" + /> + ) + + case 'date': + return ( + <Input + type="date" + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + /> + ) + + default: + // string 타입이거나 긴 텍스트인 경우 + if (variable.variableName.toLowerCase().includes('message') || + variable.variableName.toLowerCase().includes('content') || + variable.variableName.toLowerCase().includes('description')) { + return ( + <Textarea + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + placeholder="텍스트를 입력하세요" + className="min-h-[80px]" + /> + ) + } + + return ( + <Input + value={value} + onChange={(e) => updatePreviewData(variable.variableName, e.target.value)} + placeholder="텍스트를 입력하세요" + /> + ) + } + } + + return ( + <div className="space-y-6"> + {/* 상태 표시 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {template.variables.length}개 변수 + </Badge> + {lastUpdated && ( + <Badge variant="secondary" className="text-xs"> + 마지막 업데이트: {lastUpdated.toLocaleTimeString('ko-KR')} + </Badge> + )} + </div> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={copyHtml} + disabled={!previewHtml} + > + <Copy className="mr-2 h-4 w-4" /> + HTML 복사 + </Button> + <Button + variant="outline" + size="sm" + onClick={downloadHtml} + disabled={!previewHtml} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + + </div> + </div> + + <Tabs defaultValue="preview" className="space-y-6"> + <TabsList> + <TabsTrigger value="preview">미리보기</TabsTrigger> + <TabsTrigger value="data">샘플 데이터</TabsTrigger> + </TabsList> + + <TabsContent value="preview" className="space-y-4"> + {/* 뷰포트 모드 선택 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Label>뷰포트:</Label> + <div className="flex items-center gap-1"> + <Button + variant={viewMode === 'desktop' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('desktop')} + > + <Monitor className="h-4 w-4" /> + </Button> + <Button + variant={viewMode === 'tablet' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('tablet')} + > + <Tablet className="h-4 w-4" /> + </Button> + <Button + variant={viewMode === 'mobile' ? 'default' : 'outline'} + size="sm" + onClick={() => setViewMode('mobile')} + > + <Smartphone className="h-4 w-4" /> + </Button> + </div> + </div> + + <Button + onClick={generatePreview} + disabled={isLoading} + size="sm" + > + <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} /> + {isLoading ? '생성 중...' : '새로고침'} + </Button> + </div> + + {/* 미리보기 영역 */} + <div className="border rounded-lg bg-gray-100 p-4 flex justify-center"> + {previewHtml ? ( + <div + className="bg-white rounded-lg shadow-lg overflow-hidden transition-all duration-300" + style={getViewportStyle()} + > + <iframe + srcDoc={previewHtml} + sandbox="allow-same-origin" + className="w-full h-full border-0" + title="Template Preview" + /> + </div> + ) : ( + <div className="flex items-center justify-center h-[400px] text-muted-foreground"> + <div className="text-center"> + <Eye className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>미리보기를 생성하려면 새로고침 버튼을 클릭하세요.</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="data" className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">샘플 데이터 편집</h3> + <Button + variant="outline" + size="sm" + onClick={resetToDefaults} + > + 기본값으로 리셋 + </Button> + </div> + + {template.variables.length === 0 ? ( + <div className="text-center py-12 border border-dashed rounded-lg"> + <p className="text-muted-foreground">등록된 변수가 없습니다.</p> + </div> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {template.variables.map((variable) => ( + <div key={variable.id} className="space-y-2"> + <div className="flex items-center gap-2"> + <Label htmlFor={variable.variableName}> + {variable.variableName} + </Label> + <Badge variant="outline" className="text-xs"> + {variable.variableType} + </Badge> + {variable.isRequired && ( + <Badge variant="destructive" className="text-xs"> + 필수 + </Badge> + )} + </div> + + {renderInputForVariable(variable)} + + {variable.description && ( + <p className="text-xs text-muted-foreground"> + {variable.description} + </p> + )} + </div> + ))} + </div> + )} + + <div className="flex justify-end"> + <Button onClick={generatePreview} disabled={isLoading}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 업데이트 + </Button> + </div> + </TabsContent> + </Tabs> + + {/* 도움말 */} + <div className="bg-green-50 p-4 rounded-lg"> + <h4 className="font-medium text-green-900 mb-2">미리보기 가이드</h4> + <div className="text-sm text-green-800 space-y-1"> + <p>• 샘플 데이터를 수정하여 다양한 시나리오를 테스트해보세요</p> + <p>• 다양한 뷰포트에서 이메일이 어떻게 보이는지 확인하세요</p> + <p>• HTML을 복사하여 다른 이메일 클라이언트에서 테스트할 수 있습니다</p> + <p>• 변수가 올바르게 치환되는지 확인하세요</p> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-settings.tsx b/lib/email-template/editor/template-settings.tsx new file mode 100644 index 00000000..f253f87d --- /dev/null +++ b/lib/email-template/editor/template-settings.tsx @@ -0,0 +1,474 @@ +"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 { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { + Save, + Trash2, + AlertTriangle, + Info, + Calendar, + User, + Hash, + Copy +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { type TemplateWithVariables } from "@/db/schema" +import { deleteTemplate, duplicateTemplate, updateTemplateAction } from "../service" +import { TEMPLATE_CATEGORY_OPTIONS, getCategoryDisplayName } from "../validations" + + +interface TemplateSettingsProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps) { + const router = useRouter() + const [isLoading, setIsLoading] = React.useState(false) + const [formData, setFormData] = React.useState({ + name: template.name, + description: template.description || '', + category: template.category || '', + sampleData: JSON.stringify(template.sampleData || {}, null, 2) + }) + + // 폼 데이터 업데이트 + const updateFormData = (field: keyof typeof formData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + // 기본 정보 저장 + const handleSaveBasicInfo = async () => { + setIsLoading(true) + try { + // 샘플 데이터 JSON 파싱 검증 + let parsedSampleData = {} + if (formData.sampleData.trim()) { + try { + parsedSampleData = JSON.parse(formData.sampleData) + } catch (error) { + toast.error('샘플 데이터 JSON 형식이 올바르지 않습니다.') + setIsLoading(false) + return + } + } + + const result = await updateTemplateAction(template.slug, { + name: formData.name, + description: formData.description || undefined, + sampleData: parsedSampleData, + updatedBy: 'current-user-id' // TODO: 실제 사용자 ID + }) + + if (result.success) { + toast.success('템플릿 설정이 저장되었습니다.') + onUpdate({ + ...template, + name: formData.name, + description: formData.description, + sampleData: parsedSampleData, + version: template.version + 1 + }) + } else { + toast.error(result.error || '저장에 실패했습니다.') + } + } catch (error) { + toast.error('저장 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 템플릿 복제 + const handleDuplicate = async () => { + setIsLoading(true) + try { + const copyName = `${template.name} (복사본)` + const copySlug = `${template.slug}-copy-${Date.now()}` + + const result = await duplicateTemplate( + template.id, + copyName, + copySlug, + 'current-user-id' // TODO: 실제 사용자 ID + ) + + if (result.success && result.data) { + toast.success('템플릿이 복제되었습니다.') + router.push(`/evcp/templates/${result.data.slug}`) + } else { + toast.error(result.error || '복제에 실패했습니다.') + } + } catch (error) { + toast.error('복제 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 템플릿 삭제 + const handleDelete = async () => { + setIsLoading(true) + try { + const result = await deleteTemplate(template.id) + + if (result.success) { + toast.success('템플릿이 삭제되었습니다.') + router.push('/evcp/templates') + } else { + toast.error(result.error || '삭제에 실패했습니다.') + } + } catch (error) { + toast.error('삭제 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 샘플 데이터 포맷팅 + const formatSampleData = () => { + try { + const parsed = JSON.parse(formData.sampleData) + const formatted = JSON.stringify(parsed, null, 2) + updateFormData('sampleData', formatted) + toast.success('JSON이 포맷팅되었습니다.') + } catch (error) { + toast.error('유효한 JSON이 아닙니다.') + } + } + + // 기본 샘플 데이터 생성 + const generateDefaultSampleData = () => { + const defaultData: Record<string, any> = {} + + template.variables.forEach(variable => { + switch (variable.variableType) { + case 'string': + defaultData[variable.variableName] = variable.defaultValue || `샘플 ${variable.variableName}` + break + case 'number': + defaultData[variable.variableName] = variable.defaultValue ? parseFloat(variable.defaultValue) : 123 + break + case 'boolean': + defaultData[variable.variableName] = variable.defaultValue ? variable.defaultValue === 'true' : true + break + case 'date': + defaultData[variable.variableName] = variable.defaultValue || new Date().toLocaleDateString('ko-KR') + break + } + }) + + const formatted = JSON.stringify(defaultData, null, 2) + updateFormData('sampleData', formatted) + toast.success('기본 샘플 데이터가 생성되었습니다.') + } + + return ( + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + <CardDescription> + 템플릿의 기본 정보를 수정할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="name">템플릿 이름</Label> + <Input + id="name" + value={formData.name} + onChange={(e) => updateFormData('name', e.target.value)} + placeholder="템플릿 이름을 입력하세요" + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={formData.description} + onChange={(e) => updateFormData('description', e.target.value)} + placeholder="템플릿에 대한 설명을 입력하세요" + className="min-h-[100px]" + /> + </div> + + <div> + <Label htmlFor="category">카테고리</Label> + <Select + value={formData.category || "none"} // 빈 문자열일 때 "none"으로 표시 + onValueChange={(value) => { + // "none"이 선택되면 빈 문자열로 변환 + updateFormData('category', value === "none" ? "" : value) + }} + > + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">카테고리 없음</SelectItem> {/* ✅ "none" 사용 */} + {TEMPLATE_CATEGORY_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> +</div> + + <div className="flex justify-end"> + <Button onClick={handleSaveBasicInfo} disabled={isLoading}> + <Save className="mr-2 h-4 w-4" /> + {isLoading ? '저장 중...' : '기본 정보 저장'} + </Button> + </div> + </CardContent> + </Card> + + {/* 샘플 데이터 */} + <Card> + <CardHeader> + <CardTitle>샘플 데이터</CardTitle> + <CardDescription> + 미리보기에서 사용될 기본 샘플 데이터를 설정합니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={formatSampleData} + > + JSON 포맷팅 + </Button> + <Button + variant="outline" + size="sm" + onClick={generateDefaultSampleData} + > + 기본 데이터 생성 + </Button> + </div> + + <div> + <Label htmlFor="sampleData">샘플 데이터 (JSON)</Label> + <Textarea + id="sampleData" + value={formData.sampleData} + onChange={(e) => updateFormData('sampleData', e.target.value)} + placeholder='{"userName": "홍길동", "email": "user@example.com"}' + className="min-h-[200px] font-mono text-sm" + /> + </div> + + <div className="bg-blue-50 p-3 rounded-lg"> + <p className="text-sm text-blue-800"> + <Info className="inline h-4 w-4 mr-1" /> + 샘플 데이터는 템플릿 미리보기에서 기본값으로 사용됩니다. + </p> + </div> + </CardContent> + </Card> + + {/* 메타 정보 */} + <Card> + <CardHeader> + <CardTitle>메타 정보</CardTitle> + <CardDescription> + 템플릿의 상세 정보를 확인할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Hash className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">Slug:</span> + <code className="text-sm bg-muted px-2 py-1 rounded"> + {template.slug} + </code> + </div> + + <div className="flex items-center gap-2"> + <Badge variant="outline">버전 {template.version}</Badge> + <Badge variant={template.category ? "default" : "secondary"}> + {getCategoryDisplayName(template.category)} + </Badge> + </div> + + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 생성일: {new Date(template.createdAt).toLocaleString('ko-KR')} + </span> + </div> + + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 수정일: {new Date(template.updatedAt).toLocaleString('ko-KR')} + </span> + </div> + </div> + + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm"> + 생성자: {template.createdBy} + </span> + </div> + + <div> + <span className="text-sm font-medium">변수 개수:</span> + <span className="ml-2 text-sm">{template.variables.length}개</span> + </div> + + <div> + <span className="text-sm font-medium">필수 변수:</span> + <span className="ml-2 text-sm"> + {template.variables.filter(v => v.isRequired).length}개 + </span> + </div> + + <div> + <span className="text-sm font-medium">콘텐츠 길이:</span> + <span className="ml-2 text-sm">{template.content.length} 문자</span> + </div> + </div> + </div> + </CardContent> + </Card> + + <Separator /> + + {/* 위험한 작업 */} + <Card className="border-destructive"> + <CardHeader> + <CardTitle className="text-destructive">위험한 작업</CardTitle> + <CardDescription> + 다음 작업들은 신중히 수행해주세요. 일부는 되돌릴 수 없습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between p-4 border rounded-lg"> + <div> + <h4 className="font-medium">템플릿 복제</h4> + <p className="text-sm text-muted-foreground"> + 현재 템플릿을 복사하여 새로운 템플릿을 생성합니다. + </p> + </div> + <Button + variant="outline" + onClick={handleDuplicate} + disabled={isLoading} + > + <Copy className="mr-2 h-4 w-4" /> + 복제 + </Button> + </div> + + <div className="flex items-center justify-between p-4 border border-destructive rounded-lg bg-destructive/5"> + <div> + <h4 className="font-medium text-destructive">템플릿 삭제</h4> + <p className="text-sm text-muted-foreground"> + 이 템플릿을 완전히 삭제합니다. 이 작업은 되돌릴 수 없습니다. + </p> + </div> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="destructive" disabled={isLoading}> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>템플릿 삭제 확인</AlertDialogTitle> + <AlertDialogDescription> + 정말로 <strong>"{template.name}"</strong> 템플릿을 삭제하시겠습니까? + <br /> + <br /> + 이 작업은 되돌릴 수 없으며, 다음 항목들이 함께 삭제됩니다: + <br /> + • 템플릿 내용 및 설정 + <br /> + • 모든 변수 ({template.variables.length}개) + <br /> + • 변경 이력 + <br /> + <br /> + <span className="text-destructive font-medium"> + 삭제하려면 "영구 삭제"를 클릭하세요. + </span> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + <Trash2 className="mr-2 h-4 w-4" /> + 영구 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </CardContent> + </Card> + + {/* 주의사항 */} + <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg"> + <div className="flex items-start gap-3"> + <AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5" /> + <div> + <h3 className="font-semibold text-amber-800">주의사항</h3> + <div className="mt-2 text-sm text-amber-700 space-y-1"> + <p>• 템플릿 설정 변경 시 기존 미리보기가 무효화될 수 있습니다.</p> + <p>• 샘플 데이터는 유효한 JSON 형식이어야 합니다.</p> + <p>• 템플릿 삭제는 되돌릴 수 없으니 신중히 결정하세요.</p> + <p>• 카테고리 변경 시 관련 기본 변수가 영향받을 수 있습니다.</p> + </div> + </div> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/email-template/editor/template-variable-manager.tsx b/lib/email-template/editor/template-variable-manager.tsx new file mode 100644 index 00000000..9b86dd5e --- /dev/null +++ b/lib/email-template/editor/template-variable-manager.tsx @@ -0,0 +1,562 @@ +"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 { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Plus, Edit, Trash2, GripVertical, Copy } from "lucide-react" +import { toast } from "sonner" +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd" +import { type TemplateWithVariables, type TemplateVariable } from "@/db/schema" +import { addTemplateVariableAction } from "../service" + +interface TemplateVariableManagerProps { + template: TemplateWithVariables + onUpdate: (template: TemplateWithVariables) => void +} + +interface VariableFormData { + variableName: string + variableType: 'string' | 'number' | 'boolean' | 'date' + defaultValue: string + isRequired: boolean + description: string +} + +export function TemplateVariableManager({ template, onUpdate }: TemplateVariableManagerProps) { + const [variables, setVariables] = React.useState(template.variables) + // const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [editingVariable, setEditingVariable] = React.useState<TemplateVariable | null>(null) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isDialogOpen, setIsDialogOpen] = React.useState(false) // 이름 변경 + + const [formData, setFormData] = React.useState<VariableFormData>({ + variableName: '', + variableType: 'string', + defaultValue: '', + isRequired: false, + description: '' + }) + + const isEditMode = editingVariable !== null + + // 폼 초기화 + const resetForm = () => { + setFormData({ + variableName: '', + variableType: 'string', + defaultValue: '', + isRequired: false, + description: '' + }) + setEditingVariable(null) + } + + const handleEditVariable = (variable: TemplateVariable) => { + setEditingVariable(variable) + setFormData({ + variableName: variable.variableName, + variableType: variable.variableType as any, + defaultValue: variable.defaultValue || '', + isRequired: variable.isRequired || false, + description: variable.description || '' + }) + setIsDialogOpen(true) + } + + + const handleSubmitVariable = async () => { + if (!formData.variableName.trim()) { + toast.error('변수명을 입력해주세요.') + return + } + + // 편집 모드가 아닐 때만 중복 검사 + if (!isEditMode && variables.some(v => v.variableName === formData.variableName)) { + toast.error('이미 존재하는 변수명입니다.') + return + } + + // 편집 모드일 때 다른 변수와 중복되는지 검사 + if (isEditMode && variables.some(v => v.id !== editingVariable!.id && v.variableName === formData.variableName)) { + toast.error('이미 존재하는 변수명입니다.') + return + } + + // 변수명 유효성 검사 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) { + toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.') + return + } + + setIsSubmitting(true) + try { + if (isEditMode) { + // 편집 모드 - TODO: updateTemplateVariableAction 구현 필요 + // const result = await updateTemplateVariableAction(template.slug, editingVariable!.id, formData) + + // 임시로 클라이언트 사이드에서 업데이트 + const updatedVariables = variables.map(v => + v.id === editingVariable!.id + ? { ...v, ...formData } + : v + ) + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 수정되었습니다.') + } else { + // 추가 모드 + const result = await addTemplateVariableAction(template.slug, { + variableName: formData.variableName, + variableType: formData.variableType, + defaultValue: formData.defaultValue || undefined, + isRequired: formData.isRequired, + description: formData.description || undefined, + }) + + if (result.success) { + const newVariable = result.data + const updatedVariables = [...variables, newVariable] + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 추가되었습니다.') + } else { + toast.error(result.error || '변수 추가에 실패했습니다.') + return + } + } + + setIsDialogOpen(false) + resetForm() + } catch (error) { + toast.error(`변수 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다.`) + } finally { + setIsSubmitting(false) + } + } + + // Dialog 닫기 처리 + const handleDialogClose = (open: boolean) => { + setIsDialogOpen(open) + if (!open) { + resetForm() + } + } + + // 변수 추가 + // const handleAddVariable = async () => { + // if (!formData.variableName.trim()) { + // toast.error('변수명을 입력해주세요.') + // return + // } + + // // 변수명 중복 검사 + // if (variables.some(v => v.variableName === formData.variableName)) { + // toast.error('이미 존재하는 변수명입니다.') + // return + // } + + // // 변수명 유효성 검사 + // if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) { + // toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.') + // return + // } + + // setIsSubmitting(true) + // try { + // const result = await addTemplateVariableAction(template.slug, { + // variableName: formData.variableName, + // variableType: formData.variableType, + // defaultValue: formData.defaultValue || undefined, + // isRequired: formData.isRequired, + // description: formData.description || undefined, + // }) + + // if (result.success) { + // const newVariable = result.data + // const updatedVariables = [...variables, newVariable] + // setVariables(updatedVariables) + // onUpdate({ ...template, variables: updatedVariables }) + + // toast.success('변수가 추가되었습니다.') + // setIsAddDialogOpen(false) + // resetForm() + // } else { + // toast.error(result.error || '변수 추가에 실패했습니다.') + // } + // } catch (error) { + // toast.error('변수 추가 중 오류가 발생했습니다.') + // } finally { + // setIsSubmitting(false) + // } + // } + + // 변수 순서 변경 + const handleDragEnd = (result: any) => { + if (!result.destination) return + + const items = Array.from(variables) + const [reorderedItem] = items.splice(result.source.index, 1) + items.splice(result.destination.index, 0, reorderedItem) + + // displayOrder 업데이트 + const updatedItems = items.map((item, index) => ({ + ...item, + displayOrder: index + })) + + setVariables(updatedItems) + onUpdate({ ...template, variables: updatedItems }) + + // TODO: 서버에 순서 변경 요청 + toast.success('변수 순서가 변경되었습니다.') + } + + // 변수 복사 + const handleCopyVariable = (variable: TemplateVariable) => { + const copyName = `${variable.variableName}_copy` + setFormData({ + variableName: copyName, + variableType: variable.variableType as any, + defaultValue: variable.defaultValue || '', + isRequired: variable.isRequired || false, + description: variable.description || '' + }) + setIsDialogOpen(true) + } + + // 변수 삭제 + const handleDeleteVariable = async (variableId: string) => { + // TODO: 서버에서 변수 삭제 구현 + const updatedVariables = variables.filter(v => v.id !== variableId) + setVariables(updatedVariables) + onUpdate({ ...template, variables: updatedVariables }) + toast.success('변수가 삭제되었습니다.') + } + + // 변수 타입에 따른 기본값 예시 + const getDefaultValuePlaceholder = (type: string) => { + switch (type) { + case 'string': return '예: 홍길동' + case 'number': return '예: 123' + case 'boolean': return 'true 또는 false' + case 'date': return '예: 2025-01-01' + default: return '' + } + } + + // 변수 타입별 아이콘 + const getVariableTypeIcon = (type: string) => { + switch (type) { + case 'string': return '📝' + case 'number': return '🔢' + case 'boolean': return '✅' + case 'date': return '📅' + default: return '❓' + } + } + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + {/* <h3 className="text-lg font-semibold">변수 관리</h3> */} + <p className="text-sm text-muted-foreground"> + 총 {variables.length}개의 변수가 등록되어 있습니다. + </p> + </div> + + <Dialog open={isDialogOpen} onOpenChange={handleDialogClose}> + <DialogTrigger asChild> + <Button onClick={() => { resetForm(); setIsDialogOpen(true) }}> + <Plus className="mr-2 h-4 w-4" /> + 변수 추가 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {isEditMode ? '변수 수정' : '새 변수 추가'} + </DialogTitle> + <DialogDescription> + {isEditMode + ? '기존 변수의 정보를 수정합니다.' + : '템플릿에서 사용할 새로운 변수를 추가합니다.' + } + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div> + <Label htmlFor="variableName">변수명</Label> + <Input + id="variableName" + value={formData.variableName} + onChange={(e) => setFormData(prev => ({ ...prev, variableName: e.target.value }))} + placeholder="예: userName, orderDate" + /> + </div> + + <div> + <Label htmlFor="variableType">타입</Label> + <Select + value={formData.variableType} + onValueChange={(value) => setFormData(prev => ({ ...prev, variableType: value as any }))} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="string">📝 문자열</SelectItem> + <SelectItem value="number">🔢 숫자</SelectItem> + <SelectItem value="boolean">✅ 불린</SelectItem> + <SelectItem value="date">📅 날짜</SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="defaultValue">기본값</Label> + <Input + id="defaultValue" + value={formData.defaultValue} + onChange={(e) => setFormData(prev => ({ ...prev, defaultValue: e.target.value }))} + placeholder={getDefaultValuePlaceholder(formData.variableType)} + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={formData.description} + onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="변수에 대한 설명을 입력하세요" + className="min-h-[80px]" + /> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="isRequired" + checked={formData.isRequired} + onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isRequired: !!checked }))} + /> + <Label htmlFor="isRequired">필수 변수</Label> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => handleDialogClose(false)} + > + 취소 + </Button> + <Button + onClick={handleSubmitVariable} + disabled={isSubmitting} + > + {isSubmitting + ? `${isEditMode ? '수정' : '추가'} 중...` + : isEditMode ? '수정' : '추가' + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + + {/* 변수 목록 */} + {variables.length === 0 ? ( + <div className="text-center py-12 border border-dashed rounded-lg"> + <div className="text-muted-foreground"> + <Plus className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p className="text-lg font-medium">등록된 변수가 없습니다</p> + <p className="text-sm">첫 번째 변수를 추가해보세요.</p> + </div> + </div> + ) : ( + <DragDropContext onDragEnd={handleDragEnd}> + <Droppable droppableId="variables"> + {(provided) => ( + <div + {...provided.droppableProps} + ref={provided.innerRef} + className="border rounded-lg" + > + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-10"></TableHead> + <TableHead>변수명</TableHead> + <TableHead>타입</TableHead> + <TableHead>기본값</TableHead> + <TableHead>필수</TableHead> + <TableHead>설명</TableHead> + <TableHead className="w-24">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {variables.map((variable, index) => ( + <Draggable + key={variable.id} + draggableId={variable.id} + index={index} + > + {(provided, snapshot) => ( + <TableRow + ref={provided.innerRef} + {...provided.draggableProps} + className={snapshot.isDragging ? 'bg-muted' : ''} + > + <TableCell> + <div + {...provided.dragHandleProps} + className="cursor-grab hover:cursor-grabbing" + > + <GripVertical className="h-4 w-4 text-muted-foreground" /> + </div> + </TableCell> + <TableCell> + <div className="font-mono text-sm"> + {variable.variableName} + </div> + </TableCell> + <TableCell> + <Badge variant="outline" className="gap-1"> + {getVariableTypeIcon(variable.variableType)} + {variable.variableType} + </Badge> + </TableCell> + <TableCell> + <div className="text-sm text-muted-foreground max-w-[100px] truncate"> + {variable.defaultValue || '-'} + </div> + </TableCell> + <TableCell> + {variable.isRequired ? ( + <Badge variant="destructive" className="text-xs">필수</Badge> + ) : ( + <Badge variant="secondary" className="text-xs">선택</Badge> + )} + </TableCell> + <TableCell> + <div className="text-sm text-muted-foreground max-w-[200px] truncate"> + {variable.description || '-'} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleCopyVariable(variable)} + > + <Copy className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleEditVariable(variable)} + > + <Edit className="h-3 w-3" /> + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-8 w-8 text-destructive hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>변수 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 '{variable.variableName}' 변수를 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => handleDeleteVariable(variable.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </TableCell> + </TableRow> + )} + </Draggable> + ))} + {provided.placeholder} + </TableBody> + </Table> + </div> + )} + </Droppable> + </DragDropContext> + )} + + {/* 도움말 */} + <div className="bg-blue-50 p-4 rounded-lg"> + <h4 className="font-medium text-blue-900 mb-2">변수 사용법</h4> + <div className="text-sm text-blue-800 space-y-1"> + <p>• 템플릿에서 <code className="bg-blue-100 px-1 rounded">{`{{variableName}}`}</code> 형태로 사용</p> + <p>• 드래그 앤 드롭으로 변수 순서 변경 가능</p> + <p>• 필수 변수는 반드시 값이 제공되어야 함</p> + <p>• 변수명은 영문자, 숫자, 언더스코어만 사용 가능</p> + </div> + </div> + </div> + ) +}
\ No newline at end of file |
