summaryrefslogtreecommitdiff
path: root/lib/email-template/editor/template-preview.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/email-template/editor/template-preview.tsx')
-rw-r--r--lib/email-template/editor/template-preview.tsx406
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