@@ -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" ;
89import { Add , Close , Save , Visibility } from "@mui/icons-material" ;
910import { Box , Button , Chip , CircularProgress , IconButton , Stack , TextField , Typography } from "@mui/material" ;
1011import { ErrorBoundary , Suspense } from "@suspensive/react" ;
11- import { FC , useState } from "react" ;
12+ import { FC , useMemo , useRef , useState } from "react" ;
1213import { useNavigate , useParams } from "react-router-dom" ;
1314
1415import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard" ;
1516import { 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" ;
1618import { addErrorSnackbar , addSnackbar } from "@apps/pyconkr-admin/utils/snackbar" ;
1719
1820const APP = "notification/email" ;
1921const 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+
5166const 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