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-preview.tsx | |
| parent | e275618ff8a1ce6977d3e2567d943edb941897f9 (diff) | |
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/editor/template-preview.tsx')
| -rw-r--r-- | lib/email-template/editor/template-preview.tsx | 406 |
1 files changed, 406 insertions, 0 deletions
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 |
