AlertFormPanel.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. 'use client';
  2. import { useRef } from 'react';
  3. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  4. import { faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
  5. import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
  6. import { Checkbox } from '@/components/ui/checkbox';
  7. import { POPUP_EFFECTS, TEXT_EFFECTS, FONT_OPTIONS, MATCH_TYPES } from '../constants';
  8. import type { FormState, PendingFiles } from '../types';
  9. import {
  10. ALERT_TITLE_MAX_LENGTH, ALERT_MESSAGE_MAX_LENGTH, ALERT_AMOUNT_MIN,
  11. ALERT_DELAY_MIN, ALERT_DELAY_MAX, ALERT_DELAY_STEP,
  12. ALERT_DURATION_MIN, ALERT_DURATION_MAX, ALERT_DURATION_STEP,
  13. FONT_SIZE_MIN, FONT_SIZE_MAX, COLOR_HEX_MAX_LENGTH
  14. } from '@/constants/donation';
  15. type Props = {
  16. form: FormState;
  17. editingItem: AlertConfigItem|null;
  18. saving: boolean;
  19. pendingFiles: PendingFiles;
  20. onFileSelect: (file: File, type: 'image'|'sound') => void;
  21. onFormChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
  22. onSave: () => void;
  23. onCancel: () => void;
  24. };
  25. export default function AlertFormPanel({
  26. form,
  27. editingItem,
  28. saving,
  29. pendingFiles,
  30. onFileSelect,
  31. onFormChange,
  32. onSave,
  33. onCancel
  34. }: Props) {
  35. const imageInputRef = useRef<HTMLInputElement>(null);
  36. const soundInputRef = useRef<HTMLInputElement>(null);
  37. // ── 폰트 그룹 렌더러 ─────────────────────────────
  38. const renderFontGroup = (
  39. label: string,
  40. prefix: string,
  41. familyField: 'nicknameFontFamily'|'amountFontFamily'|'messageFontFamily'|'templateFontFamily',
  42. sizeField: 'nicknameFontSize'|'amountFontSize'|'messageFontSize'|'templateFontSize',
  43. colorField: 'nicknameFontColor'|'amountFontColor'|'messageFontColor'|'templateFontColor',
  44. defaultSize: number
  45. ) => (
  46. <div className="alert-config__font-group">
  47. <div className="alert-config__font-group-title">{label}</div>
  48. <div className="alert-config__font-grid">
  49. <div className="alert-config__field">
  50. <label htmlFor={`${prefix}-family`} className="alert-config__field-label">글꼴</label>
  51. <select
  52. id={`${prefix}-family`}
  53. className="alert-config__select"
  54. value={form[familyField] ?? ''}
  55. onChange={e => onFormChange(familyField, e.target.value || null)}
  56. >
  57. {FONT_OPTIONS.map(f => (
  58. <option key={f.value} value={f.value}>{f.label}</option>
  59. ))}
  60. </select>
  61. </div>
  62. <div className="alert-config__field">
  63. <label htmlFor={`${prefix}-size`} className="alert-config__field-label">크기 (px)</label>
  64. <input
  65. id={`${prefix}-size`}
  66. type="number"
  67. className="alert-config__input"
  68. min={FONT_SIZE_MIN}
  69. max={FONT_SIZE_MAX}
  70. value={form[sizeField]}
  71. onChange={e => onFormChange(sizeField, parseInt(e.target.value) || defaultSize)}
  72. title='Font Size'
  73. />
  74. </div>
  75. <div className="alert-config__field">
  76. <label htmlFor={`${prefix}-color`} className="alert-config__field-label">색상</label>
  77. <div className="alert-config__color-wrap">
  78. <input
  79. id={`${prefix}-color`}
  80. type="color"
  81. className="alert-config__color-input"
  82. value={form[colorField]}
  83. onChange={e => onFormChange(colorField, e.target.value)}
  84. />
  85. <input
  86. type="text"
  87. className="alert-config__input alert-config__color-text"
  88. value={form[colorField]}
  89. onChange={e => onFormChange(colorField, e.target.value)}
  90. maxLength={COLOR_HEX_MAX_LENGTH}
  91. title='Font Color'
  92. />
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. );
  98. return (
  99. <main className="alert-config__form-panel">
  100. {/* ── 기본 설정 ────────────────────────────── */}
  101. <section className="alert-config__section">
  102. <h3 className="alert-config__section-title">기본 설정</h3>
  103. <div className="alert-config__section-body">
  104. <div className="alert-config__field">
  105. <label htmlFor="config-title" className="alert-config__field-label">제목</label>
  106. <input
  107. id="config-title"
  108. type="text"
  109. className="alert-config__input"
  110. value={form.title}
  111. onChange={e => onFormChange('title', e.target.value)}
  112. placeholder="알림 제목 (선택)"
  113. maxLength={ALERT_TITLE_MAX_LENGTH}
  114. />
  115. </div>
  116. <div className="alert-config__field-row">
  117. <div className="alert-config__field">
  118. <label htmlFor="config-amount" className="alert-config__field-label">금액 (원)</label>
  119. <input
  120. id="config-amount"
  121. type="number"
  122. className="alert-config__input"
  123. min={ALERT_AMOUNT_MIN}
  124. value={form.amount}
  125. onChange={e => onFormChange('amount', parseInt(e.target.value) || 0)}
  126. />
  127. </div>
  128. <div className="alert-config__field">
  129. <label className="alert-config__field-label">조건</label>
  130. <div className="alert-config__radio-group">
  131. {MATCH_TYPES.map(mt => (
  132. <label key={mt.value} className="alert-config__radio-label">
  133. <input
  134. type="radio"
  135. name="matchType"
  136. value={mt.value}
  137. checked={form.matchType === mt.value}
  138. onChange={() => onFormChange('matchType', mt.value)}
  139. />
  140. {mt.label}
  141. </label>
  142. ))}
  143. </div>
  144. </div>
  145. </div>
  146. <div className="alert-config__field">
  147. <label htmlFor="config-message" className="alert-config__field-label">메시지</label>
  148. <input
  149. id="config-message"
  150. type="text"
  151. className="alert-config__input"
  152. value={form.message}
  153. onChange={e => onFormChange('message', e.target.value)}
  154. placeholder="{이름}님이 {금액}원을 후원했습니다!"
  155. maxLength={ALERT_MESSAGE_MAX_LENGTH}
  156. />
  157. <span className="alert-config__field-hint">
  158. 사용 가능한 변수: {'{이름}'}, {'{금액}'}
  159. </span>
  160. </div>
  161. <div className="alert-config__field-row">
  162. <div className="alert-config__field">
  163. <label htmlFor="config-play-delay" className="alert-config__field-label">재생 지연 (초)</label>
  164. <input
  165. id="config-play-delay"
  166. type="number"
  167. className="alert-config__input"
  168. min={ALERT_DELAY_MIN}
  169. max={ALERT_DELAY_MAX}
  170. step={ALERT_DELAY_STEP}
  171. value={form.playDelaySec}
  172. onChange={e => onFormChange('playDelaySec', parseFloat(e.target.value) || 0)}
  173. />
  174. </div>
  175. <div className="alert-config__field">
  176. <label htmlFor="config-display-duration" className="alert-config__field-label">노출 시간 (초)</label>
  177. <input
  178. id="config-display-duration"
  179. type="number"
  180. className="alert-config__input"
  181. min={ALERT_DURATION_MIN}
  182. max={ALERT_DURATION_MAX}
  183. step={ALERT_DURATION_STEP}
  184. value={form.displayDurationSec}
  185. onChange={e => onFormChange('displayDurationSec', parseFloat(e.target.value) || 0)}
  186. />
  187. </div>
  188. </div>
  189. <div className="alert-config__field">
  190. <label htmlFor="config-is-active" className="alert-config__checkbox-label">
  191. <Checkbox
  192. id="config-is-active"
  193. checked={form.isActive}
  194. onCheckedChange={v => onFormChange('isActive', !!v)}
  195. />
  196. 알림을 사용합니다.
  197. </label>
  198. </div>
  199. </div>
  200. </section>
  201. {/* ── 효과 ────────────────────────────────── */}
  202. <section className="alert-config__section">
  203. <h3 className="alert-config__section-title">효과</h3>
  204. <div className="alert-config__section-body">
  205. <div className="alert-config__field-row">
  206. <div className="alert-config__field">
  207. <label htmlFor="config-popup-effect" className="alert-config__field-label">팝업 효과</label>
  208. <select
  209. id="config-popup-effect"
  210. className="alert-config__select"
  211. value={form.popupEffect ?? ''}
  212. onChange={e => onFormChange('popupEffect', e.target.value || null)}
  213. >
  214. {POPUP_EFFECTS.map(e => (
  215. <option key={e.value} value={e.value}>{e.label}</option>
  216. ))}
  217. </select>
  218. </div>
  219. <div className="alert-config__field">
  220. <label htmlFor="config-text-effect" className="alert-config__field-label">텍스트 효과</label>
  221. <select
  222. id="config-text-effect"
  223. className="alert-config__select"
  224. value={form.textEffect ?? ''}
  225. onChange={e => onFormChange('textEffect', e.target.value || null)}
  226. >
  227. {TEXT_EFFECTS.map(e => (
  228. <option key={e.value} value={e.value}>{e.label}</option>
  229. ))}
  230. </select>
  231. </div>
  232. </div>
  233. </div>
  234. </section>
  235. {/* ── 폰트 ────────────────────────────────── */}
  236. <section className="alert-config__section">
  237. <h3 className="alert-config__section-title">폰트</h3>
  238. <div className="alert-config__section-body">
  239. {renderFontGroup('이름', 'font-nickname', 'nicknameFontFamily', 'nicknameFontSize', 'nicknameFontColor', 24)}
  240. {renderFontGroup('금액', 'font-amount', 'amountFontFamily', 'amountFontSize', 'amountFontColor', 24)}
  241. {renderFontGroup('보낼 내용', 'font-message', 'messageFontFamily', 'messageFontSize', 'messageFontColor', 18)}
  242. {renderFontGroup('알림 문구', 'font-template', 'templateFontFamily', 'templateFontSize', 'templateFontColor', 24)}
  243. </div>
  244. </section>
  245. {/* ── 미디어 ───────────────────────────────── */}
  246. <section className="alert-config__section">
  247. <h3 className="alert-config__section-title">미디어</h3>
  248. <div className="alert-config__section-body">
  249. {/* 이미지 */}
  250. <div className="alert-config__field">
  251. <label htmlFor="config-enable-image" className="alert-config__checkbox-label">
  252. <Checkbox
  253. id="config-enable-image"
  254. checked={form.enableImage}
  255. onCheckedChange={v => onFormChange('enableImage', !!v)}
  256. />
  257. 이미지 사용
  258. </label>
  259. </div>
  260. {form.enableImage && (
  261. <div className="alert-config__media-upload">
  262. {form.imageUrl && (
  263. <div className="alert-config__media-preview">
  264. <img src={form.imageUrl} alt="알림 이미지" />
  265. <button
  266. type="button"
  267. className="alert-config__media-remove"
  268. onClick={() => onFormChange('imageUrl', null)}
  269. title="삭제"
  270. >
  271. <FontAwesomeIcon icon={faTrash} />
  272. </button>
  273. </div>
  274. )}
  275. <input
  276. ref={imageInputRef}
  277. type="file"
  278. accept=".jpg,.jpeg,.png,.gif"
  279. className="alert-config__file-hidden"
  280. onChange={e => {
  281. const file = e.target.files?.[0];
  282. if (file) onFileSelect(file, 'image');
  283. e.target.value = '';
  284. }}
  285. title='이미지 첨부'
  286. />
  287. <button
  288. type="button"
  289. className="alert-config__btn"
  290. onClick={() => imageInputRef.current?.click()}
  291. disabled={saving}
  292. >
  293. <FontAwesomeIcon icon={faUpload} />
  294. 이미지 선택
  295. </button>
  296. {pendingFiles.image && (
  297. <span className="alert-config__field-hint">{pendingFiles.image.name}</span>
  298. )}
  299. <span className="alert-config__field-hint">JPG, PNG, GIF (최대 20MB)</span>
  300. </div>
  301. )}
  302. {/* 사운드 */}
  303. <div className="alert-config__field">
  304. <label htmlFor="config-enable-sound" className="alert-config__checkbox-label">
  305. <Checkbox
  306. id="config-enable-sound"
  307. checked={form.enableSound}
  308. onCheckedChange={v => onFormChange('enableSound', !!v)}
  309. />
  310. 사운드 사용
  311. </label>
  312. </div>
  313. {form.enableSound && (
  314. <div className="alert-config__media-upload">
  315. {form.soundUrl && (
  316. <div className="alert-config__media-preview alert-config__media-preview--audio">
  317. <audio controls src={form.soundUrl} />
  318. <button
  319. type="button"
  320. className="alert-config__media-remove"
  321. onClick={() => onFormChange('soundUrl', null)}
  322. title="삭제"
  323. >
  324. <FontAwesomeIcon icon={faTrash} />
  325. </button>
  326. </div>
  327. )}
  328. <input
  329. ref={soundInputRef}
  330. type="file"
  331. accept=".mp3,.ogg,.wav,.m4a"
  332. className="alert-config__file-hidden"
  333. onChange={e => {
  334. const file = e.target.files?.[0];
  335. if (file) onFileSelect(file, 'sound');
  336. e.target.value = '';
  337. }}
  338. title='사운드 첨부'
  339. />
  340. <button
  341. type="button"
  342. className="alert-config__btn"
  343. onClick={() => soundInputRef.current?.click()}
  344. disabled={saving}
  345. >
  346. <FontAwesomeIcon icon={faUpload} />
  347. 사운드 선택
  348. </button>
  349. {pendingFiles.sound && (
  350. <span className="alert-config__field-hint">{pendingFiles.sound.name}</span>
  351. )}
  352. <span className="alert-config__field-hint">MP3, OGG, WAV, M4A (최대 50MB)</span>
  353. </div>
  354. )}
  355. </div>
  356. </section>
  357. {/* ── 하단 버튼 ────────────────────────────── */}
  358. <div className="alert-config__form-footer flex-1 w-full sm:justify-end gap-2">
  359. <button type="button" className="alert-config__btn flex-1 sm:flex-none" onClick={onCancel}>
  360. 취소
  361. </button>
  362. <button
  363. type="button"
  364. className="alert-config__btn alert-config__btn--primary flex-1 sm:flex-none"
  365. onClick={onSave}
  366. disabled={saving}
  367. >
  368. {saving ? '저장 중...' : (editingItem ? '수정하기' : '등록하기')}
  369. </button>
  370. </div>
  371. </main>
  372. );
  373. }