1128 lines
44 KiB
TypeScript
1128 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } 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,
|
|
Upload,
|
|
X,
|
|
GripVertical,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
LayoutTemplate,
|
|
Table2,
|
|
AlertCircle,
|
|
Minus,
|
|
Building2,
|
|
ListOrdered,
|
|
} from "lucide-react";
|
|
import { getMailTemplates } from "@/lib/api/mail";
|
|
|
|
export interface MailComponent {
|
|
id: string;
|
|
type:
|
|
| "text"
|
|
| "button"
|
|
| "image"
|
|
| "spacer"
|
|
| "table"
|
|
| "header"
|
|
| "infoTable"
|
|
| "alertBox"
|
|
| "divider"
|
|
| "footer"
|
|
| "numberedList";
|
|
content?: string;
|
|
text?: string;
|
|
url?: string;
|
|
src?: string;
|
|
height?: number;
|
|
styles?: Record<string, string>;
|
|
// 헤더 컴포넌트용
|
|
logoSrc?: string;
|
|
brandName?: string;
|
|
sendDate?: string;
|
|
headerBgColor?: string;
|
|
// 정보 테이블용
|
|
rows?: Array<{ label: string; value: string }>;
|
|
tableTitle?: string;
|
|
// 강조 박스용
|
|
alertType?: "info" | "warning" | "danger" | "success";
|
|
alertTitle?: string;
|
|
// 푸터용
|
|
companyName?: string;
|
|
ceoName?: string;
|
|
businessNumber?: string;
|
|
address?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
copyright?: string;
|
|
// 번호 리스트용
|
|
listItems?: string[];
|
|
listTitle?: 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 [isLoading, setIsLoading] = useState(false);
|
|
|
|
// 드래그 앤 드롭 상태
|
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
|
// 템플릿 데이터 로드 (수정 모드)
|
|
useEffect(() => {
|
|
if (templateId) {
|
|
loadTemplate(templateId);
|
|
}
|
|
}, [templateId]);
|
|
|
|
const loadTemplate = async (id: string) => {
|
|
try {
|
|
setIsLoading(true);
|
|
const templates = await getMailTemplates();
|
|
const template = templates.find((t) => t.id === id);
|
|
|
|
if (template) {
|
|
setTemplateName(template.name);
|
|
setSubject(template.subject);
|
|
setComponents(template.components || []);
|
|
console.log("✅ 템플릿 로드 완료:", {
|
|
name: template.name,
|
|
components: template.components?.length || 0,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 템플릿 로드 실패:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 컴포넌트 타입 정의
|
|
const componentTypes = [
|
|
// 레이아웃 컴포넌트
|
|
{
|
|
type: "header",
|
|
icon: LayoutTemplate,
|
|
label: "헤더",
|
|
color: "bg-indigo-100 hover:bg-indigo-200",
|
|
category: "layout",
|
|
},
|
|
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
|
|
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
|
|
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
|
|
// 컨텐츠 컴포넌트
|
|
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
|
|
{
|
|
type: "button",
|
|
icon: MousePointer,
|
|
label: "버튼",
|
|
color: "bg-success/20 hover:bg-success/30",
|
|
category: "content",
|
|
},
|
|
{
|
|
type: "image",
|
|
icon: ImageIcon,
|
|
label: "이미지",
|
|
color: "bg-purple-100 hover:bg-purple-200",
|
|
category: "content",
|
|
},
|
|
{
|
|
type: "infoTable",
|
|
icon: Table2,
|
|
label: "정보 테이블",
|
|
color: "bg-cyan-100 hover:bg-cyan-200",
|
|
category: "content",
|
|
},
|
|
{
|
|
type: "alertBox",
|
|
icon: AlertCircle,
|
|
label: "안내 박스",
|
|
color: "bg-amber-100 hover:bg-amber-200",
|
|
category: "content",
|
|
},
|
|
{
|
|
type: "numberedList",
|
|
icon: ListOrdered,
|
|
label: "번호 리스트",
|
|
color: "bg-emerald-100 hover:bg-emerald-200",
|
|
category: "content",
|
|
},
|
|
];
|
|
|
|
// 컴포넌트 추가
|
|
const addComponent = (type: string) => {
|
|
const newComponent: MailComponent = {
|
|
id: `comp-${Date.now()}`,
|
|
type: type as any,
|
|
content: type === "text" ? "" : undefined,
|
|
text: type === "button" ? "버튼 텍스트" : undefined,
|
|
url: type === "button" || type === "image" ? "" : undefined,
|
|
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
|
|
height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
|
|
styles: {
|
|
padding: type === "divider" ? "0" : "10px",
|
|
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
|
color: type === "button" ? "#fff" : "#333",
|
|
},
|
|
// 헤더 기본값
|
|
logoSrc: type === "header" ? "" : undefined,
|
|
brandName: type === "header" ? "회사명" : undefined,
|
|
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
|
|
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
|
|
// 정보 테이블 기본값
|
|
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
|
|
tableTitle: type === "infoTable" ? "" : undefined,
|
|
// 안내 박스 기본값
|
|
alertType: type === "alertBox" ? "info" : undefined,
|
|
alertTitle: type === "alertBox" ? "안내" : undefined,
|
|
// 푸터 기본값
|
|
companyName: type === "footer" ? "회사명" : undefined,
|
|
ceoName: type === "footer" ? "" : undefined,
|
|
businessNumber: type === "footer" ? "" : undefined,
|
|
address: type === "footer" ? "" : undefined,
|
|
phone: type === "footer" ? "" : undefined,
|
|
email: type === "footer" ? "" : undefined,
|
|
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
|
|
// 번호 리스트 기본값
|
|
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
|
|
listTitle: type === "numberedList" ? "" : undefined,
|
|
};
|
|
|
|
setComponents([...components, newComponent]);
|
|
};
|
|
|
|
// 드래그 앤 드롭 핸들러
|
|
const handleDragStart = (index: number) => {
|
|
setDraggedIndex(index);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
e.preventDefault();
|
|
if (draggedIndex !== null && draggedIndex !== index) {
|
|
setDragOverIndex(index);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (index: number) => {
|
|
if (draggedIndex !== null && draggedIndex !== index) {
|
|
moveComponent(draggedIndex, index);
|
|
}
|
|
setDraggedIndex(null);
|
|
setDragOverIndex(null);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedIndex(null);
|
|
setDragOverIndex(null);
|
|
};
|
|
|
|
const moveComponent = (fromIndex: number, toIndex: number) => {
|
|
const newComponents = [...components];
|
|
const [movedItem] = newComponents.splice(fromIndex, 1);
|
|
newComponents.splice(toIndex, 0, movedItem);
|
|
setComponents(newComponents);
|
|
};
|
|
|
|
// 컴포넌트 삭제
|
|
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);
|
|
|
|
// 로딩 중일 때
|
|
if (isLoading) {
|
|
return (
|
|
<div className="bg-muted/30 flex h-screen items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-orange-500"></div>
|
|
<p className="text-muted-foreground">템플릿을 불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-muted/30 flex h-screen">
|
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
|
<div className="w-64 space-y-4 overflow-y-auto border-r bg-white p-4">
|
|
{/* 레이아웃 컴포넌트 */}
|
|
<div>
|
|
<h3 className="text-foreground mb-3 flex items-center text-sm font-semibold">
|
|
<LayoutTemplate className="mr-2 h-4 w-4 text-indigo-500" />
|
|
레이아웃
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{componentTypes
|
|
.filter((c) => c.category === "layout")
|
|
.map(({ type, icon: Icon, label, color }) => (
|
|
<Button
|
|
key={type}
|
|
onClick={() => addComponent(type)}
|
|
variant="outline"
|
|
className={`w-full justify-start ${color} border`}
|
|
>
|
|
<Icon className="mr-2 h-4 w-4" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컨텐츠 컴포넌트 */}
|
|
<div>
|
|
<h3 className="text-foreground mb-3 flex items-center text-sm font-semibold">
|
|
<Mail className="text-primary mr-2 h-4 w-4" />
|
|
컨텐츠
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{componentTypes
|
|
.filter((c) => c.category === "content")
|
|
.map(({ type, icon: Icon, label, color }) => (
|
|
<Button
|
|
key={type}
|
|
onClick={() => addComponent(type)}
|
|
variant="outline"
|
|
className={`w-full justify-start ${color} border`}
|
|
>
|
|
<Icon className="mr-2 h-4 w-4" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 템플릿 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">템플릿 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="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="mr-2 h-4 w-4" />
|
|
저장
|
|
</Button>
|
|
<Button onClick={handlePreview} className="w-full" variant="outline">
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
미리보기
|
|
</Button>
|
|
<Button onClick={handleSend} variant="default" className="w-full">
|
|
<Send className="mr-2 h-4 w-4" />
|
|
발송
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 캔버스 */}
|
|
<div className="flex-1 overflow-y-auto p-8">
|
|
<Card className="mx-auto max-w-3xl">
|
|
<CardHeader className="from-muted to-muted border-b bg-gradient-to-r">
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>메일 미리보기</span>
|
|
<span className="text-muted-foreground text-sm font-normal">{components.length}개 컴포넌트</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* 제목 영역 */}
|
|
{subject && (
|
|
<div className="bg-muted/30 border-b p-6">
|
|
<p className="text-muted-foreground text-sm">제목:</p>
|
|
<p className="text-lg font-semibold">{subject}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 컴포넌트 렌더링 */}
|
|
<div className="space-y-4 p-6 pl-14">
|
|
{components.length === 0 ? (
|
|
<div className="text-muted-foreground/50 py-16 text-center">
|
|
<Mail className="mx-auto mb-4 h-16 w-16 opacity-20" />
|
|
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
|
</div>
|
|
) : (
|
|
components.map((comp, index) => (
|
|
<div
|
|
key={comp.id}
|
|
draggable
|
|
onDragStart={() => handleDragStart(index)}
|
|
onDragOver={(e) => handleDragOver(e, index)}
|
|
onDrop={() => handleDrop(index)}
|
|
onDragEnd={handleDragEnd}
|
|
onClick={() => selectComponent(comp.id)}
|
|
className={`group relative cursor-pointer rounded-lg transition-all ${
|
|
selectedComponent === comp.id
|
|
? "bg-orange-50/30 ring-2 ring-orange-500"
|
|
: "hover:ring-2 hover:ring-gray-300"
|
|
} ${draggedIndex === index ? "scale-95 opacity-50" : ""} ${
|
|
dragOverIndex === index ? "ring-primary ring-dashed bg-primary/10 ring-2" : ""
|
|
}`}
|
|
style={comp.styles}
|
|
>
|
|
{/* 드래그 핸들 & 순서 이동 버튼 */}
|
|
<div className="absolute top-1/2 -left-10 flex -translate-y-1/2 flex-col items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (index > 0) moveComponent(index, index - 1);
|
|
}}
|
|
className="rounded p-1 hover:bg-gray-200 disabled:opacity-30"
|
|
disabled={index === 0}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</button>
|
|
<div className="cursor-grab rounded p-1 hover:bg-gray-200 active:cursor-grabbing">
|
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (index < components.length - 1) moveComponent(index, index + 1);
|
|
}}
|
|
className="rounded p-1 hover:bg-gray-200 disabled:opacity-30"
|
|
disabled={index === components.length - 1}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 순서 배지 */}
|
|
<div className="absolute top-0 -left-10 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
|
|
{index + 1}
|
|
</div>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeComponent(comp.id);
|
|
}}
|
|
className="bg-destructive hover:bg-destructive/90 absolute top-2 right-2 rounded-full p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
>
|
|
<Trash2 className="h-4 w-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 rounded-md px-6 py-3"
|
|
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` }}
|
|
className="flex items-center justify-center rounded bg-gray-100 text-xs text-gray-400"
|
|
>
|
|
여백 {comp.height}px
|
|
</div>
|
|
)}
|
|
{comp.type === "header" && (
|
|
<div className="rounded-lg p-4" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
|
|
<span className="text-lg font-bold">{comp.brandName}</span>
|
|
</div>
|
|
<span className="text-sm text-gray-500">{comp.sendDate}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{comp.type === "infoTable" && (
|
|
<div className="overflow-hidden rounded-lg border">
|
|
{comp.tableTitle && (
|
|
<div className="border-b bg-gray-50 px-4 py-2 font-semibold">{comp.tableTitle}</div>
|
|
)}
|
|
<table className="w-full">
|
|
<tbody>
|
|
{comp.rows?.map((row, i) => (
|
|
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
|
|
<td className="w-1/3 border-r px-4 py-2 font-medium text-gray-600">{row.label}</td>
|
|
<td className="px-4 py-2">{row.value}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{comp.type === "alertBox" && (
|
|
<div
|
|
className={`rounded-lg border-l-4 p-4 ${
|
|
comp.alertType === "info"
|
|
? "border-blue-500 bg-blue-50 text-blue-800"
|
|
: comp.alertType === "warning"
|
|
? "border-amber-500 bg-amber-50 text-amber-800"
|
|
: comp.alertType === "danger"
|
|
? "border-red-500 bg-red-50 text-red-800"
|
|
: "border-emerald-500 bg-emerald-50 text-emerald-800"
|
|
}`}
|
|
>
|
|
{comp.alertTitle && <div className="mb-1 font-bold">{comp.alertTitle}</div>}
|
|
<div>{comp.content}</div>
|
|
</div>
|
|
)}
|
|
{comp.type === "divider" && (
|
|
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
|
|
)}
|
|
{comp.type === "footer" && (
|
|
<div className="border-t bg-gray-50 py-4 text-center text-sm text-gray-500">
|
|
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
|
|
{(comp.ceoName || comp.businessNumber) && (
|
|
<div className="mt-1">
|
|
{comp.ceoName && <span>대표: {comp.ceoName}</span>}
|
|
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
|
|
{comp.businessNumber && <span>사업자등록번호: {comp.businessNumber}</span>}
|
|
</div>
|
|
)}
|
|
{comp.address && <div className="mt-1">{comp.address}</div>}
|
|
{(comp.phone || comp.email) && (
|
|
<div className="mt-1">
|
|
{comp.phone && <span>Tel: {comp.phone}</span>}
|
|
{comp.phone && comp.email && <span className="mx-2">|</span>}
|
|
{comp.email && <span>Email: {comp.email}</span>}
|
|
</div>
|
|
)}
|
|
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
|
|
</div>
|
|
)}
|
|
{comp.type === "numberedList" && (
|
|
<div className="p-4">
|
|
{comp.listTitle && <div className="mb-2 font-semibold">{comp.listTitle}</div>}
|
|
<ol className="list-inside list-decimal space-y-1">
|
|
{comp.listItems?.map((item, i) => (
|
|
<li key={i}>{item}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 오른쪽: 속성 패널 */}
|
|
<div className="bg-background w-80 overflow-y-auto border-l p-4">
|
|
{selected ? (
|
|
<div className="space-y-4">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="text-foreground flex items-center text-sm font-semibold">
|
|
<Settings className="text-primary mr-2 h-4 w-4" />
|
|
속성 편집
|
|
</h3>
|
|
<Button size="sm" variant="ghost" onClick={() => setSelectedComponent(null)}>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 텍스트 컴포넌트 */}
|
|
{selected.type === "text" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">내용</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">메일에 표시될 텍스트를 입력하세요</p>
|
|
<Textarea
|
|
value={(() => {
|
|
// 🎯 HTML 태그 자동 제거 (비개발자 친화적)
|
|
const content = selected.content || "";
|
|
const tmp = document.createElement("DIV");
|
|
tmp.innerHTML = content;
|
|
return tmp.textContent || tmp.innerText || "";
|
|
})()}
|
|
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
|
onFocus={(e) => {
|
|
// 🎯 클릭 시 placeholder 같은 텍스트 자동 제거
|
|
const currentValue = e.target.value.trim();
|
|
if (currentValue === "텍스트를 입력하세요" || currentValue === "텍스트를 입력하세요...") {
|
|
e.target.value = "";
|
|
updateComponent(selected.id, { content: "" });
|
|
}
|
|
}}
|
|
rows={8}
|
|
className="mt-1"
|
|
placeholder="예) 안녕하세요! 특별한 소식을 전해드립니다..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 버튼 컴포넌트 */}
|
|
{selected.type === "button" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">버튼 텍스트</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">버튼에 표시될 글자를 입력하세요</p>
|
|
<Input
|
|
value={selected.text || ""}
|
|
onChange={(e) => updateComponent(selected.id, { text: e.target.value })}
|
|
className="mt-1"
|
|
placeholder="예) 자세히 보기, 지금 시작하기"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">연결할 주소</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">
|
|
버튼을 클릭하면 이동할 웹사이트 주소를 입력하세요
|
|
</p>
|
|
<Input
|
|
value={selected.url || ""}
|
|
onChange={(e) => updateComponent(selected.id, { url: e.target.value })}
|
|
className="mt-1"
|
|
placeholder="예) https://www.example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">버튼 색상</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">버튼의 배경색을 선택하세요</p>
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
type="color"
|
|
value={selected.styles?.backgroundColor || "#007bff"}
|
|
onChange={(e) =>
|
|
updateComponent(selected.id, {
|
|
styles: { ...selected.styles, backgroundColor: e.target.value },
|
|
})
|
|
}
|
|
className="h-10 w-16 cursor-pointer"
|
|
/>
|
|
<span className="text-muted-foreground text-sm">
|
|
{selected.styles?.backgroundColor || "#007bff"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 이미지 컴포넌트 */}
|
|
{selected.type === "image" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">이미지 선택</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">컴퓨터에서 이미지 파일을 선택하세요</p>
|
|
|
|
{/* 파일 업로드 버튼 */}
|
|
<div className="space-y-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = (e: any) => {
|
|
const file = e.target?.files?.[0];
|
|
if (file) {
|
|
// 파일을 Base64로 변환
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
updateComponent(selected.id, {
|
|
src: event.target?.result as string,
|
|
});
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
input.click();
|
|
}}
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
이미지 파일 선택
|
|
</Button>
|
|
|
|
{/* 구분선 */}
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="w-full border border-t"></div>
|
|
</div>
|
|
<div className="relative flex justify-center text-xs">
|
|
<span className="text-muted-foreground bg-white px-2">또는</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL 입력 */}
|
|
<div>
|
|
<Label className="text-muted-foreground mb-1 block text-xs">이미지 웹 주소 입력</Label>
|
|
<Input
|
|
value={selected.src || ""}
|
|
onChange={(e) => updateComponent(selected.id, { src: e.target.value })}
|
|
className="text-sm"
|
|
placeholder="예) https://example.com/image.jpg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 미리보기 */}
|
|
{selected.src && (
|
|
<div className="mt-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<p className="text-muted-foreground text-xs">미리보기:</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => updateComponent(selected.id, { src: "" })}
|
|
className="h-6 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
>
|
|
<X className="mr-1 h-3 w-3" />
|
|
이미지 제거
|
|
</Button>
|
|
</div>
|
|
<div className="bg-muted/30 rounded-lg border p-2">
|
|
<img
|
|
src={selected.src}
|
|
alt="미리보기"
|
|
className="w-full rounded"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = "https://placehold.co/600x200?text=이미지+로드+실패";
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 여백 컴포넌트 */}
|
|
{selected.type === "spacer" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-foreground flex items-center gap-2 text-sm font-medium">여백 크기</Label>
|
|
<p className="text-muted-foreground mt-1 mb-2 text-xs">요소 사이의 간격을 조절하세요</p>
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
type="number"
|
|
value={selected.height || 20}
|
|
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 20 })}
|
|
className="w-24"
|
|
min="0"
|
|
max="200"
|
|
/>
|
|
<span className="text-muted-foreground text-sm">픽셀</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 헤더 컴포넌트 */}
|
|
{selected.type === "header" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>브랜드명</Label>
|
|
<Input
|
|
value={selected.brandName || ""}
|
|
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
|
|
placeholder="회사명"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>로고 이미지 URL</Label>
|
|
<Input
|
|
value={selected.logoSrc || ""}
|
|
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
|
|
placeholder="https://example.com/logo.png"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>발송일</Label>
|
|
<Input
|
|
value={selected.sendDate || ""}
|
|
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>배경색</Label>
|
|
<div className="mt-1 flex items-center gap-3">
|
|
<Input
|
|
type="color"
|
|
value={selected.headerBgColor || "#f8f9fa"}
|
|
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
|
|
className="h-10 w-16"
|
|
/>
|
|
<span className="text-muted-foreground text-sm">{selected.headerBgColor || "#f8f9fa"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 정보 테이블 컴포넌트 */}
|
|
{selected.type === "infoTable" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>테이블 제목</Label>
|
|
<Input
|
|
value={selected.tableTitle || ""}
|
|
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
|
|
placeholder="예: 주문 정보"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>테이블 항목</Label>
|
|
<div className="mt-2 space-y-2">
|
|
{selected.rows?.map((row, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<Input
|
|
value={row.label}
|
|
onChange={(e) => {
|
|
const newRows = [...(selected.rows || [])];
|
|
newRows[i] = { ...newRows[i], label: e.target.value };
|
|
updateComponent(selected.id, { rows: newRows });
|
|
}}
|
|
placeholder="항목명"
|
|
className="flex-1"
|
|
/>
|
|
<Input
|
|
value={row.value}
|
|
onChange={(e) => {
|
|
const newRows = [...(selected.rows || [])];
|
|
newRows[i] = { ...newRows[i], value: e.target.value };
|
|
updateComponent(selected.id, { rows: newRows });
|
|
}}
|
|
placeholder="값"
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newRows = selected.rows?.filter((_, idx) => idx !== i);
|
|
updateComponent(selected.id, { rows: newRows });
|
|
}}
|
|
>
|
|
<Trash2 className="text-destructive h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newRows = [...(selected.rows || []), { label: "", value: "" }];
|
|
updateComponent(selected.id, { rows: newRows });
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
항목 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 안내 박스 컴포넌트 */}
|
|
{selected.type === "alertBox" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>박스 유형</Label>
|
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
{(["info", "warning", "danger", "success"] as const).map((type) => (
|
|
<Button
|
|
key={type}
|
|
variant={selected.alertType === type ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => updateComponent(selected.id, { alertType: type })}
|
|
className={
|
|
type === "info"
|
|
? "border-blue-300"
|
|
: type === "warning"
|
|
? "border-amber-300"
|
|
: type === "danger"
|
|
? "border-red-300"
|
|
: "border-emerald-300"
|
|
}
|
|
>
|
|
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>제목</Label>
|
|
<Input
|
|
value={selected.alertTitle || ""}
|
|
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
|
|
placeholder="안내 제목"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>내용</Label>
|
|
<Textarea
|
|
value={selected.content || ""}
|
|
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
|
placeholder="안내 내용을 입력하세요"
|
|
rows={4}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 구분선 컴포넌트 */}
|
|
{selected.type === "divider" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>선 두께</Label>
|
|
<div className="mt-1 flex items-center gap-3">
|
|
<Input
|
|
type="number"
|
|
value={selected.height || 1}
|
|
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
|
|
className="w-24"
|
|
min="1"
|
|
max="10"
|
|
/>
|
|
<span className="text-muted-foreground text-sm">픽셀</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 푸터 컴포넌트 */}
|
|
{selected.type === "footer" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>회사명</Label>
|
|
<Input
|
|
value={selected.companyName || ""}
|
|
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>대표자</Label>
|
|
<Input
|
|
value={selected.ceoName || ""}
|
|
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>사업자등록번호</Label>
|
|
<Input
|
|
value={selected.businessNumber || ""}
|
|
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
|
|
placeholder="000-00-00000"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>주소</Label>
|
|
<Input
|
|
value={selected.address || ""}
|
|
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>전화번호</Label>
|
|
<Input
|
|
value={selected.phone || ""}
|
|
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>이메일</Label>
|
|
<Input
|
|
value={selected.email || ""}
|
|
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>저작권 문구</Label>
|
|
<Input
|
|
value={selected.copyright || ""}
|
|
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 번호 리스트 컴포넌트 */}
|
|
{selected.type === "numberedList" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>리스트 제목</Label>
|
|
<Input
|
|
value={selected.listTitle || ""}
|
|
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
|
|
placeholder="예: 안내 사항"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>항목</Label>
|
|
<div className="mt-2 space-y-2">
|
|
{selected.listItems?.map((item, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<span className="text-muted-foreground flex w-6 items-center justify-center text-sm">
|
|
{i + 1}.
|
|
</span>
|
|
<Input
|
|
value={item}
|
|
onChange={(e) => {
|
|
const newItems = [...(selected.listItems || [])];
|
|
newItems[i] = e.target.value;
|
|
updateComponent(selected.id, { listItems: newItems });
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
|
|
updateComponent(selected.id, { listItems: newItems });
|
|
}}
|
|
>
|
|
<Trash2 className="text-destructive h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newItems = [...(selected.listItems || []), ""];
|
|
updateComponent(selected.id, { listItems: newItems });
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
항목 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground py-16 text-center">
|
|
<Settings className="mx-auto mb-4 h-12 w-12 opacity-20" />
|
|
<p className="text-sm">컴포넌트를 선택하세요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|