402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Mail,
|
|
Type,
|
|
Image as ImageIcon,
|
|
Square,
|
|
MousePointer,
|
|
Eye,
|
|
Send,
|
|
Save,
|
|
Plus,
|
|
Trash2,
|
|
Settings
|
|
} from "lucide-react";
|
|
|
|
export interface MailComponent {
|
|
id: string;
|
|
type: "text" | "button" | "image" | "spacer" | "table";
|
|
content?: string;
|
|
text?: string;
|
|
url?: string;
|
|
src?: string;
|
|
height?: number;
|
|
styles?: Record<string, string>;
|
|
}
|
|
|
|
export interface QueryConfig {
|
|
id: string;
|
|
name: string;
|
|
sql: string;
|
|
parameters: Array<{
|
|
name: string;
|
|
type: string;
|
|
value?: any;
|
|
}>;
|
|
}
|
|
|
|
interface MailDesignerProps {
|
|
templateId?: string;
|
|
onSave?: (data: any) => void;
|
|
onPreview?: (data: any) => void;
|
|
onSend?: (data: any) => void;
|
|
}
|
|
|
|
export default function MailDesigner({
|
|
templateId,
|
|
onSave,
|
|
onPreview,
|
|
onSend,
|
|
}: MailDesignerProps) {
|
|
const [components, setComponents] = useState<MailComponent[]>([]);
|
|
const [selectedComponent, setSelectedComponent] = useState<string | null>(null);
|
|
const [templateName, setTemplateName] = useState("");
|
|
const [subject, setSubject] = useState("");
|
|
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
|
|
|
// 컴포넌트 타입 정의
|
|
const componentTypes = [
|
|
{ type: "text", icon: Type, label: "텍스트", color: "bg-blue-100 hover:bg-blue-200" },
|
|
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" },
|
|
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
|
{ type: "spacer", icon: Square, label: "여백", color: "bg-gray-100 hover:bg-gray-200" },
|
|
];
|
|
|
|
// 컴포넌트 추가
|
|
const addComponent = (type: string) => {
|
|
const newComponent: MailComponent = {
|
|
id: `comp-${Date.now()}`,
|
|
type: type as any,
|
|
content: type === "text" ? "<p>텍스트를 입력하세요...</p>" : undefined,
|
|
text: type === "button" ? "버튼" : undefined,
|
|
url: type === "button" || type === "image" ? "https://example.com" : undefined,
|
|
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=Image" : undefined,
|
|
height: type === "spacer" ? 20 : undefined,
|
|
styles: {
|
|
padding: "10px",
|
|
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
|
color: type === "button" ? "#fff" : "#333",
|
|
},
|
|
};
|
|
|
|
setComponents([...components, newComponent]);
|
|
};
|
|
|
|
// 컴포넌트 삭제
|
|
const removeComponent = (id: string) => {
|
|
setComponents(components.filter(c => c.id !== id));
|
|
if (selectedComponent === id) {
|
|
setSelectedComponent(null);
|
|
}
|
|
};
|
|
|
|
// 컴포넌트 선택
|
|
const selectComponent = (id: string) => {
|
|
setSelectedComponent(id);
|
|
};
|
|
|
|
// 컴포넌트 내용 업데이트
|
|
const updateComponent = (id: string, updates: Partial<MailComponent>) => {
|
|
setComponents(
|
|
components.map(c => c.id === id ? { ...c, ...updates } : c)
|
|
);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = () => {
|
|
const data = {
|
|
name: templateName,
|
|
subject,
|
|
components,
|
|
queries,
|
|
};
|
|
|
|
if (onSave) {
|
|
onSave(data);
|
|
}
|
|
};
|
|
|
|
// 미리보기
|
|
const handlePreview = () => {
|
|
if (onPreview) {
|
|
onPreview({ components, subject });
|
|
}
|
|
};
|
|
|
|
// 발송
|
|
const handleSend = () => {
|
|
if (onSend) {
|
|
onSend({ components, subject, queries });
|
|
}
|
|
};
|
|
|
|
// 선택된 컴포넌트 가져오기
|
|
const selected = components.find(c => c.id === selectedComponent);
|
|
|
|
return (
|
|
<div className="flex h-screen bg-gray-50">
|
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
|
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
|
<Mail className="w-4 h-4 mr-2 text-orange-500" />
|
|
컴포넌트
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
|
<Button
|
|
key={type}
|
|
onClick={() => addComponent(type)}
|
|
variant="outline"
|
|
className={`w-full justify-start ${color} border-gray-300`}
|
|
>
|
|
<Icon className="w-4 h-4 mr-2" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 템플릿 정보 */}
|
|
<Card>
|
|
<CardHeader className="p-4 pb-2">
|
|
<CardTitle className="text-sm">템플릿 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 pt-2 space-y-3">
|
|
<div>
|
|
<Label className="text-xs">템플릿 이름</Label>
|
|
<Input
|
|
value={templateName}
|
|
onChange={(e) => setTemplateName(e.target.value)}
|
|
placeholder="예: 고객 환영 메일"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">제목</Label>
|
|
<Input
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
placeholder="예: {customer_name}님 환영합니다!"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 액션 버튼 */}
|
|
<div className="space-y-2">
|
|
<Button onClick={handleSave} className="w-full" variant="default">
|
|
<Save className="w-4 h-4 mr-2" />
|
|
저장
|
|
</Button>
|
|
<Button onClick={handlePreview} className="w-full" variant="outline">
|
|
<Eye className="w-4 h-4 mr-2" />
|
|
미리보기
|
|
</Button>
|
|
<Button onClick={handleSend} className="w-full bg-orange-500 hover:bg-orange-600 text-white">
|
|
<Send className="w-4 h-4 mr-2" />
|
|
발송
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 캔버스 */}
|
|
<div className="flex-1 p-8 overflow-y-auto">
|
|
<Card className="max-w-3xl mx-auto">
|
|
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 border-b">
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>메일 미리보기</span>
|
|
<span className="text-sm text-gray-500 font-normal">
|
|
{components.length}개 컴포넌트
|
|
</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* 제목 영역 */}
|
|
{subject && (
|
|
<div className="p-6 bg-gray-50 border-b">
|
|
<p className="text-sm text-gray-500">제목:</p>
|
|
<p className="font-semibold text-lg">{subject}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 컴포넌트 렌더링 */}
|
|
<div className="p-6 space-y-4">
|
|
{components.length === 0 ? (
|
|
<div className="text-center py-16 text-gray-400">
|
|
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
|
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
|
</div>
|
|
) : (
|
|
components.map((comp) => (
|
|
<div
|
|
key={comp.id}
|
|
onClick={() => selectComponent(comp.id)}
|
|
className={`relative group cursor-pointer rounded-lg transition-all ${
|
|
selectedComponent === comp.id
|
|
? "ring-2 ring-orange-500 bg-orange-50/30"
|
|
: "hover:ring-2 hover:ring-gray-300"
|
|
}`}
|
|
style={comp.styles}
|
|
>
|
|
{/* 삭제 버튼 */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeComponent(comp.id);
|
|
}}
|
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* 컴포넌트 내용 */}
|
|
{comp.type === "text" && (
|
|
<div dangerouslySetInnerHTML={{ __html: comp.content || "" }} />
|
|
)}
|
|
{comp.type === "button" && (
|
|
<a
|
|
href={comp.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-block px-6 py-3 rounded-md"
|
|
style={comp.styles}
|
|
>
|
|
{comp.text}
|
|
</a>
|
|
)}
|
|
{comp.type === "image" && (
|
|
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
|
)}
|
|
{comp.type === "spacer" && (
|
|
<div style={{ height: `${comp.height}px` }} />
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 오른쪽: 속성 패널 */}
|
|
<div className="w-80 bg-white border-l p-4 overflow-y-auto">
|
|
{selected ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold text-gray-700 flex items-center">
|
|
<Settings className="w-4 h-4 mr-2 text-orange-500" />
|
|
속성 편집
|
|
</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setSelectedComponent(null)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 텍스트 컴포넌트 */}
|
|
{selected.type === "text" && (
|
|
<div>
|
|
<Label className="text-xs">내용 (HTML)</Label>
|
|
<Textarea
|
|
value={selected.content || ""}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, { content: e.target.value })
|
|
}
|
|
rows={8}
|
|
className="mt-1 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 버튼 컴포넌트 */}
|
|
{selected.type === "button" && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">버튼 텍스트</Label>
|
|
<Input
|
|
value={selected.text || ""}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, { text: e.target.value })
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">링크 URL</Label>
|
|
<Input
|
|
value={selected.url || ""}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, { url: e.target.value })
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">배경색</Label>
|
|
<Input
|
|
type="color"
|
|
value={selected.styles?.backgroundColor || "#007bff"}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, {
|
|
styles: { ...selected.styles, backgroundColor: e.target.value },
|
|
})
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 이미지 컴포넌트 */}
|
|
{selected.type === "image" && (
|
|
<div>
|
|
<Label className="text-xs">이미지 URL</Label>
|
|
<Input
|
|
value={selected.src || ""}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, { src: e.target.value })
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 여백 컴포넌트 */}
|
|
{selected.type === "spacer" && (
|
|
<div>
|
|
<Label className="text-xs">높이 (px)</Label>
|
|
<Input
|
|
type="number"
|
|
value={selected.height || 20}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, { height: parseInt(e.target.value) })
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16 text-gray-400">
|
|
<Settings className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
|
<p className="text-sm">컴포넌트를 선택하세요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|