From 9da494b0e3bbe7b513521d0915510fe9ee376b8b Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 21 Jul 2025 07:19:52 +0000
Subject: (대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항
 반영
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/email-template/editor/template-preview.tsx | 406 +++++++++++++++++++++++++
 1 file changed, 406 insertions(+)
 create mode 100644 lib/email-template/editor/template-preview.tsx

(limited to 'lib/email-template/editor/template-preview.tsx')

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
-- 
cgit v1.2.3

