Skip to content

Commit 79596d0

Browse files
committed
feat: 이메일 템플릿 에디터 적용
1 parent 11fb8ae commit 79596d0

4 files changed

Lines changed: 1408 additions & 31 deletions

File tree

apps/pyconkr-admin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "@apps/pyconkr-admin",
33
"dependencies": {
44
"@frontend/common": "workspace:*",
5-
"@frontend/shop": "workspace:*"
5+
"@frontend/shop": "workspace:*",
6+
"@mu-software/mail-editor": "^0.3.0"
67
},
78
"devDependencies": {
89
"vite": "^6.3.5",
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { type EmailDocument } from "@mu-software/mail-editor";
2+
3+
export const DEFAULT_INITIAL_DOCUMENT: EmailDocument = {
4+
version: 1,
5+
meta: {},
6+
styles: { width: 480 },
7+
rows: [
8+
{
9+
id: "r_3ddf1139",
10+
columns: [
11+
{
12+
id: "c_a695ad0b",
13+
width: 1,
14+
blocks: [
15+
{
16+
id: "b_f95ab413",
17+
type: "image",
18+
src: "https://placehold.co/50?text=Image",
19+
alt: "",
20+
width: 50,
21+
height: 50,
22+
styles: { textAlign: "center" },
23+
},
24+
],
25+
},
26+
{
27+
id: "c_6aa69e48",
28+
width: 1,
29+
blocks: [
30+
{ id: "b_1c330070", type: "heading", level: 2, content: "PyCon 한국 {{ year }}" },
31+
{ id: "b_04e8a2aa", type: "text", content: "PyCon Korea {{ year }}", styles: { fontSize: 14, lineHeight: 1.5 } },
32+
],
33+
},
34+
],
35+
styles: { paddingY: 16, paddingX: 24 },
36+
},
37+
{
38+
id: "r_607ff09c",
39+
columns: [
40+
{
41+
id: "c_d1031624",
42+
width: 1,
43+
blocks: [{ id: "b_320bc51e", type: "hr" }],
44+
styles: { verticalAlign: "middle" },
45+
},
46+
],
47+
styles: {},
48+
},
49+
{
50+
id: "r_3d6709f5",
51+
columns: [
52+
{
53+
id: "c_6f7fecc4",
54+
width: 1,
55+
blocks: [{ id: "b_6a24e1fe", type: "heading", level: 2, content: "본문 제목" }],
56+
},
57+
],
58+
styles: { paddingY: 16, paddingX: 24 },
59+
},
60+
{
61+
id: "r_22657034",
62+
columns: [
63+
{
64+
id: "c_0d3a384a",
65+
width: 1,
66+
blocks: [{ id: "b_7e7d1363", type: "text", content: "여기에 내용을 입력하세요", styles: { fontSize: 14, lineHeight: 1.5 } }],
67+
},
68+
],
69+
styles: { paddingY: 16, paddingX: 24 },
70+
},
71+
{
72+
id: "r_e4b1c3b7",
73+
columns: [
74+
{
75+
id: "c_3aa3d6a8",
76+
width: 1,
77+
blocks: [{ id: "b_d73a03e7", type: "hr" }],
78+
},
79+
],
80+
styles: {},
81+
},
82+
{
83+
id: "r_76bf6162",
84+
columns: [
85+
{
86+
id: "c_90cc6a63",
87+
width: 1,
88+
blocks: [
89+
{
90+
id: "b_ff12aba8",
91+
type: "unorderedList",
92+
items: [
93+
"본 메일은 [파이콘 한국 {{ year }}] 참가등록을 완료하신 분들께 보내드리는 안내 메일입니다.<br>This is an information email sent to those who have completed the registration for.",
94+
"본 메일 주소는 발신 전용 메일로 회신이 되지 않습니다.<br>This email address is for sending only and cannot be replied to.",
95+
"등록 시 메일 주소 입력 착오로 인해 잘못된 메일로 발신이 될 수 있습니다.<br>본인이 아닌 경우 양해 부탁드리며, 본 메일을 삭제해주세요.<br>Due to an error in the email address entered during registration,<br>it may be sent to the wrong email address.<br>If it is not you, please ignore it and delete this email.",
96+
],
97+
styles: { lineHeight: 1.5, fontSize: 10 },
98+
},
99+
],
100+
},
101+
],
102+
styles: { paddingY: 16, paddingX: 24 },
103+
},
104+
],
105+
sampleValues: { year: "2026" },
106+
};

apps/pyconkr-admin/src/components/pages/notification/email_template_editor.tsx

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,34 @@ import {
55
useRetrieveQuery,
66
useUpdateMutation,
77
} from "@frontend/common/hooks/useAdminAPI";
8+
import { type EmailDocument, MailEditor, type MailEditorHandle, parseEmailDocument } from "@mu-software/mail-editor";
89
import { Add, Close, Save, Visibility } from "@mui/icons-material";
910
import { Box, Button, Chip, CircularProgress, IconButton, Stack, TextField, Typography } from "@mui/material";
1011
import { ErrorBoundary, Suspense } from "@suspensive/react";
11-
import { FC, useState } from "react";
12+
import { FC, useMemo, useRef, useState } from "react";
1213
import { useNavigate, useParams } from "react-router-dom";
1314

1415
import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard";
1516
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
17+
import { DEFAULT_INITIAL_DOCUMENT } from "@apps/pyconkr-admin/components/pages/notification/email_template_default_document";
1618
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
1719

1820
const APP = "notification/email";
1921
const RESOURCE = "template";
2022

21-
type EmailTemplateFormData = {
23+
type EmailTemplateMetaFormData = {
2224
code: string;
2325
title: string;
2426
description: string;
25-
data: string;
2627
sent_from: string;
2728
};
2829

29-
type EmailTemplateSchema = EmailTemplateFormData & {
30+
type EmailTemplatePayload = EmailTemplateMetaFormData & {
31+
data: string;
32+
editor_source: EmailDocument;
33+
};
34+
35+
type EmailTemplateSchema = EmailTemplatePayload & {
3036
id: string;
3137
created_at: string;
3238
created_by: string | null;
@@ -48,6 +54,15 @@ const isValidJson = (s: string): boolean => {
4854
}
4955
};
5056

57+
const toInitialDocument = (source: EmailTemplateSchema["editor_source"] | undefined): EmailDocument => {
58+
if (!source) return DEFAULT_INITIAL_DOCUMENT;
59+
try {
60+
return typeof source === "string" ? parseEmailDocument(source) : source;
61+
} catch {
62+
return DEFAULT_INITIAL_DOCUMENT;
63+
}
64+
};
65+
5166
const InnerAdminEmailTemplateEditor: FC = ErrorBoundary.with(
5267
{ fallback: ErrorFallback },
5368
Suspense.with({ fallback: <CircularProgress /> }, () => {
@@ -56,37 +71,47 @@ const InnerAdminEmailTemplateEditor: FC = ErrorBoundary.with(
5671
const backendAdminClient = useBackendAdminClient();
5772
const { data: retrievedData } = useRetrieveQuery<EmailTemplateSchema>(backendAdminClient, APP, RESOURCE, id || "");
5873

59-
const [formData, setFormData] = useState<EmailTemplateFormData>(() => ({
74+
const [meta, setMeta] = useState<EmailTemplateMetaFormData>(() => ({
6075
code: retrievedData?.code ?? "",
6176
title: retrievedData?.title ?? "",
6277
description: retrievedData?.description ?? "",
63-
data: retrievedData?.data ?? "",
6478
sent_from: retrievedData?.sent_from ?? "",
6579
}));
6680
const [contextJson, setContextJson] = useState("{}");
6781

68-
const createMutation = useCreateMutation<EmailTemplateFormData>(backendAdminClient, APP, RESOURCE);
69-
const updateMutation = useUpdateMutation<EmailTemplateFormData>(backendAdminClient, APP, RESOURCE, id || "");
82+
const editorRef = useRef<MailEditorHandle>(null);
83+
const initialDocument = useMemo(() => toInitialDocument(retrievedData?.editor_source), [retrievedData?.editor_source]);
84+
85+
const createMutation = useCreateMutation<EmailTemplatePayload>(backendAdminClient, APP, RESOURCE);
86+
const updateMutation = useUpdateMutation<EmailTemplatePayload>(backendAdminClient, APP, RESOURCE, id || "");
7087
const renderMutation = useRenderTemplateMutation(backendAdminClient, APP, RESOURCE);
7188

72-
const setField = <K extends keyof EmailTemplateFormData>(key: K, value: EmailTemplateFormData[K]) => setFormData((p) => ({ ...p, [key]: value }));
89+
const setField = <K extends keyof EmailTemplateMetaFormData>(key: K, value: EmailTemplateMetaFormData[K]) =>
90+
setMeta((p) => ({ ...p, [key]: value }));
7391
const onClose = () => navigate(`/${APP}/${RESOURCE}`);
7492

7593
const isPending = createMutation.isPending || updateMutation.isPending;
7694
const jsonValid = isValidJson(contextJson);
7795

78-
const handleSubmit = () => {
96+
const handleSubmit = async () => {
7997
if (isPending) return;
98+
if (!editorRef.current) {
99+
addSnackbar("에디터가 아직 준비되지 않았습니다.", "error");
100+
return;
101+
}
102+
const editor_source = editorRef.current.exportEmailDocument();
103+
const data = await editorRef.current.exportHTML();
104+
const payload: EmailTemplatePayload = { ...meta, data, editor_source };
80105
if (id) {
81-
updateMutation.mutate(formData, {
106+
updateMutation.mutate(payload, {
82107
onSuccess: () => addSnackbar("수정했습니다.", "success"),
83108
onError: addErrorSnackbar,
84109
});
85110
} else {
86-
createMutation.mutate(formData, {
87-
onSuccess: (data) => {
111+
createMutation.mutate(payload, {
112+
onSuccess: (created) => {
88113
addSnackbar("생성했습니다.", "success");
89-
const newId = (data as EmailTemplateFormData & { id?: string }).id;
114+
const newId = (created as EmailTemplatePayload & { id?: string }).id;
90115
if (newId) navigate(`/${APP}/${RESOURCE}/${newId}`);
91116
},
92117
onError: addErrorSnackbar,
@@ -109,32 +134,35 @@ const InnerAdminEmailTemplateEditor: FC = ErrorBoundary.with(
109134
<IconButton onClick={onClose} children={<Close />} />
110135
</Stack>
111136
<Stack spacing={2} sx={{ my: 2 }}>
112-
<TextField label="code" value={formData.code} onChange={(e) => setField("code", e.target.value)} fullWidth />
113-
<TextField label="title" value={formData.title} onChange={(e) => setField("title", e.target.value)} fullWidth />
137+
<TextField label="code" value={meta.code} onChange={(e) => setField("code", e.target.value)} fullWidth />
138+
<TextField label="title" value={meta.title} onChange={(e) => setField("title", e.target.value)} fullWidth />
114139
<TextField
115140
label="description"
116-
value={formData.description}
141+
value={meta.description}
117142
onChange={(e) => setField("description", e.target.value)}
118143
multiline
119144
minRows={2}
120145
fullWidth
121146
/>
122147
<TextField
123148
label="sent_from"
124-
value={formData.sent_from}
149+
value={meta.sent_from}
125150
onChange={(e) => setField("sent_from", e.target.value)}
126151
helperText="발신 이메일 주소"
127152
fullWidth
128153
/>
129-
<TextField
130-
label="data"
131-
value={formData.data}
132-
onChange={(e) => setField("data", e.target.value)}
133-
helperText="이메일 본문 (HTML/MJML). 변수는 {{ name }} 형식으로 사용."
134-
multiline
135-
minRows={8}
136-
fullWidth
137-
/>
154+
155+
<Box>
156+
<Typography variant="subtitle1" sx={{ mb: 1 }}>
157+
본문 에디터
158+
</Typography>
159+
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1 }}>
160+
변수는 {"{{ name }}"} 형식으로 사용합니다. 저장 시 EmailDocument JSON은 editor_source에, 렌더된 HTML은 data 필드에 기록됩니다.
161+
</Typography>
162+
<Box sx={{ height: 800, border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden" }}>
163+
<MailEditor ref={editorRef} initialDocument={initialDocument} />
164+
</Box>
165+
</Box>
138166

139167
{retrievedData && retrievedData.template_variables.length > 0 && (
140168
<Box>
@@ -180,7 +208,7 @@ const InnerAdminEmailTemplateEditor: FC = ErrorBoundary.with(
180208
/>
181209
) : (
182210
<Typography variant="body2" color="text.secondary">
183-
미리보기 갱신 버튼을 눌러주세요.
211+
미리보기 갱신 버튼을 눌러주세요. 최신 본문을 미리보려면 먼저 저장해주세요.
184212
</Typography>
185213
)}
186214
</Stack>

0 commit comments

Comments
 (0)