|
|
@@ -0,0 +1,389 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useRef } from 'react';
|
|
|
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
|
+import { faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
|
|
|
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
|
|
|
+import { Checkbox } from '@/components/ui/checkbox';
|
|
|
+import { POPUP_EFFECTS, TEXT_EFFECTS, FONT_OPTIONS, MATCH_TYPES } from '../constants';
|
|
|
+import type { FormState, PendingFiles } from '../types';
|
|
|
+import {
|
|
|
+ ALERT_TITLE_MAX_LENGTH, ALERT_MESSAGE_MAX_LENGTH, ALERT_AMOUNT_MIN,
|
|
|
+ ALERT_DELAY_MIN, ALERT_DELAY_MAX, ALERT_DELAY_STEP,
|
|
|
+ ALERT_DURATION_MIN, ALERT_DURATION_MAX, ALERT_DURATION_STEP,
|
|
|
+ FONT_SIZE_MIN, FONT_SIZE_MAX, COLOR_HEX_MAX_LENGTH
|
|
|
+} from '@/constants/donation';
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ form: FormState;
|
|
|
+ editingItem: AlertConfigItem|null;
|
|
|
+ saving: boolean;
|
|
|
+ pendingFiles: PendingFiles;
|
|
|
+ onFileSelect: (file: File, type: 'image'|'sound') => void;
|
|
|
+ onFormChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
|
|
+ onSave: () => void;
|
|
|
+ onCancel: () => void;
|
|
|
+};
|
|
|
+
|
|
|
+export default function AlertFormPanel({
|
|
|
+ form,
|
|
|
+ editingItem,
|
|
|
+ saving,
|
|
|
+ pendingFiles,
|
|
|
+ onFileSelect,
|
|
|
+ onFormChange,
|
|
|
+ onSave,
|
|
|
+ onCancel
|
|
|
+}: Props) {
|
|
|
+ const imageInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const soundInputRef = useRef<HTMLInputElement>(null);
|
|
|
+
|
|
|
+ // ── 폰트 그룹 렌더러 ─────────────────────────────
|
|
|
+ const renderFontGroup = (
|
|
|
+ label: string,
|
|
|
+ prefix: string,
|
|
|
+ familyField: 'nicknameFontFamily'|'amountFontFamily'|'messageFontFamily'|'templateFontFamily',
|
|
|
+ sizeField: 'nicknameFontSize'|'amountFontSize'|'messageFontSize'|'templateFontSize',
|
|
|
+ colorField: 'nicknameFontColor'|'amountFontColor'|'messageFontColor'|'templateFontColor',
|
|
|
+ defaultSize: number
|
|
|
+ ) => (
|
|
|
+ <div className="alert-config__font-group">
|
|
|
+ <div className="alert-config__font-group-title">{label}</div>
|
|
|
+ <div className="alert-config__font-grid">
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor={`${prefix}-family`} className="alert-config__field-label">글꼴</label>
|
|
|
+ <select
|
|
|
+ id={`${prefix}-family`}
|
|
|
+ className="alert-config__select"
|
|
|
+ value={form[familyField] ?? ''}
|
|
|
+ onChange={e => onFormChange(familyField, e.target.value || null)}
|
|
|
+ >
|
|
|
+ {FONT_OPTIONS.map(f => (
|
|
|
+ <option key={f.value} value={f.value}>{f.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor={`${prefix}-size`} className="alert-config__field-label">크기 (px)</label>
|
|
|
+ <input
|
|
|
+ id={`${prefix}-size`}
|
|
|
+ type="number"
|
|
|
+ className="alert-config__input"
|
|
|
+ min={FONT_SIZE_MIN}
|
|
|
+ max={FONT_SIZE_MAX}
|
|
|
+ value={form[sizeField]}
|
|
|
+ onChange={e => onFormChange(sizeField, parseInt(e.target.value) || defaultSize)}
|
|
|
+ title='Font Size'
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor={`${prefix}-color`} className="alert-config__field-label">색상</label>
|
|
|
+ <div className="alert-config__color-wrap">
|
|
|
+ <input
|
|
|
+ id={`${prefix}-color`}
|
|
|
+ type="color"
|
|
|
+ className="alert-config__color-input"
|
|
|
+ value={form[colorField]}
|
|
|
+ onChange={e => onFormChange(colorField, e.target.value)}
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ className="alert-config__input alert-config__color-text"
|
|
|
+ value={form[colorField]}
|
|
|
+ onChange={e => onFormChange(colorField, e.target.value)}
|
|
|
+ maxLength={COLOR_HEX_MAX_LENGTH}
|
|
|
+ title='Font Color'
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <main className="alert-config__form-panel">
|
|
|
+ {/* ── 기본 설정 ────────────────────────────── */}
|
|
|
+ <section className="alert-config__section">
|
|
|
+ <h3 className="alert-config__section-title">기본 설정</h3>
|
|
|
+ <div className="alert-config__section-body">
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-title" className="alert-config__field-label">제목</label>
|
|
|
+ <input
|
|
|
+ id="config-title"
|
|
|
+ type="text"
|
|
|
+ className="alert-config__input"
|
|
|
+ value={form.title}
|
|
|
+ onChange={e => onFormChange('title', e.target.value)}
|
|
|
+ placeholder="알림 제목 (선택)"
|
|
|
+ maxLength={ALERT_TITLE_MAX_LENGTH}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="alert-config__field-row">
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-amount" className="alert-config__field-label">금액 (원)</label>
|
|
|
+ <input
|
|
|
+ id="config-amount"
|
|
|
+ type="number"
|
|
|
+ className="alert-config__input"
|
|
|
+ min={ALERT_AMOUNT_MIN}
|
|
|
+ value={form.amount}
|
|
|
+ onChange={e => onFormChange('amount', parseInt(e.target.value) || 0)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label className="alert-config__field-label">조건</label>
|
|
|
+ <div className="alert-config__radio-group">
|
|
|
+ {MATCH_TYPES.map(mt => (
|
|
|
+ <label key={mt.value} className="alert-config__radio-label">
|
|
|
+ <input
|
|
|
+ type="radio"
|
|
|
+ name="matchType"
|
|
|
+ value={mt.value}
|
|
|
+ checked={form.matchType === mt.value}
|
|
|
+ onChange={() => onFormChange('matchType', mt.value)}
|
|
|
+ />
|
|
|
+ {mt.label}
|
|
|
+ </label>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-message" className="alert-config__field-label">메시지</label>
|
|
|
+ <input
|
|
|
+ id="config-message"
|
|
|
+ type="text"
|
|
|
+ className="alert-config__input"
|
|
|
+ value={form.message}
|
|
|
+ onChange={e => onFormChange('message', e.target.value)}
|
|
|
+ placeholder="{이름}님이 {금액}원을 후원했습니다!"
|
|
|
+ maxLength={ALERT_MESSAGE_MAX_LENGTH}
|
|
|
+ />
|
|
|
+ <span className="alert-config__field-hint">
|
|
|
+ 사용 가능한 변수: {'{이름}'}, {'{금액}'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="alert-config__field-row">
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-play-delay" className="alert-config__field-label">재생 지연 (초)</label>
|
|
|
+ <input
|
|
|
+ id="config-play-delay"
|
|
|
+ type="number"
|
|
|
+ className="alert-config__input"
|
|
|
+ min={ALERT_DELAY_MIN}
|
|
|
+ max={ALERT_DELAY_MAX}
|
|
|
+ step={ALERT_DELAY_STEP}
|
|
|
+ value={form.playDelaySec}
|
|
|
+ onChange={e => onFormChange('playDelaySec', parseFloat(e.target.value) || 0)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-display-duration" className="alert-config__field-label">노출 시간 (초)</label>
|
|
|
+ <input
|
|
|
+ id="config-display-duration"
|
|
|
+ type="number"
|
|
|
+ className="alert-config__input"
|
|
|
+ min={ALERT_DURATION_MIN}
|
|
|
+ max={ALERT_DURATION_MAX}
|
|
|
+ step={ALERT_DURATION_STEP}
|
|
|
+ value={form.displayDurationSec}
|
|
|
+ onChange={e => onFormChange('displayDurationSec', parseFloat(e.target.value) || 0)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-is-active" className="alert-config__checkbox-label">
|
|
|
+ <Checkbox
|
|
|
+ id="config-is-active"
|
|
|
+ checked={form.isActive}
|
|
|
+ onCheckedChange={v => onFormChange('isActive', !!v)}
|
|
|
+ />
|
|
|
+ 알림을 사용합니다.
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* ── 효과 ────────────────────────────────── */}
|
|
|
+ <section className="alert-config__section">
|
|
|
+ <h3 className="alert-config__section-title">효과</h3>
|
|
|
+ <div className="alert-config__section-body">
|
|
|
+ <div className="alert-config__field-row">
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-popup-effect" className="alert-config__field-label">팝업 효과</label>
|
|
|
+ <select
|
|
|
+ id="config-popup-effect"
|
|
|
+ className="alert-config__select"
|
|
|
+ value={form.popupEffect ?? ''}
|
|
|
+ onChange={e => onFormChange('popupEffect', e.target.value || null)}
|
|
|
+ >
|
|
|
+ {POPUP_EFFECTS.map(e => (
|
|
|
+ <option key={e.value} value={e.value}>{e.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-text-effect" className="alert-config__field-label">텍스트 효과</label>
|
|
|
+ <select
|
|
|
+ id="config-text-effect"
|
|
|
+ className="alert-config__select"
|
|
|
+ value={form.textEffect ?? ''}
|
|
|
+ onChange={e => onFormChange('textEffect', e.target.value || null)}
|
|
|
+ >
|
|
|
+ {TEXT_EFFECTS.map(e => (
|
|
|
+ <option key={e.value} value={e.value}>{e.label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* ── 폰트 ────────────────────────────────── */}
|
|
|
+ <section className="alert-config__section">
|
|
|
+ <h3 className="alert-config__section-title">폰트</h3>
|
|
|
+ <div className="alert-config__section-body">
|
|
|
+ {renderFontGroup('이름', 'font-nickname', 'nicknameFontFamily', 'nicknameFontSize', 'nicknameFontColor', 24)}
|
|
|
+ {renderFontGroup('금액', 'font-amount', 'amountFontFamily', 'amountFontSize', 'amountFontColor', 24)}
|
|
|
+ {renderFontGroup('보낼 내용', 'font-message', 'messageFontFamily', 'messageFontSize', 'messageFontColor', 18)}
|
|
|
+ {renderFontGroup('알림 문구', 'font-template', 'templateFontFamily', 'templateFontSize', 'templateFontColor', 24)}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* ── 미디어 ───────────────────────────────── */}
|
|
|
+ <section className="alert-config__section">
|
|
|
+ <h3 className="alert-config__section-title">미디어</h3>
|
|
|
+ <div className="alert-config__section-body">
|
|
|
+
|
|
|
+ {/* 이미지 */}
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-enable-image" className="alert-config__checkbox-label">
|
|
|
+ <Checkbox
|
|
|
+ id="config-enable-image"
|
|
|
+ checked={form.enableImage}
|
|
|
+ onCheckedChange={v => onFormChange('enableImage', !!v)}
|
|
|
+ />
|
|
|
+ 이미지 사용
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {form.enableImage && (
|
|
|
+ <div className="alert-config__media-upload">
|
|
|
+ {form.imageUrl && (
|
|
|
+ <div className="alert-config__media-preview">
|
|
|
+ <img src={form.imageUrl} alt="알림 이미지" />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="alert-config__media-remove"
|
|
|
+ onClick={() => onFormChange('imageUrl', null)}
|
|
|
+ title="삭제"
|
|
|
+ >
|
|
|
+ <FontAwesomeIcon icon={faTrash} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <input
|
|
|
+ ref={imageInputRef}
|
|
|
+ type="file"
|
|
|
+ accept=".jpg,.jpeg,.png,.gif"
|
|
|
+ className="alert-config__file-hidden"
|
|
|
+ onChange={e => {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (file) onFileSelect(file, 'image');
|
|
|
+ e.target.value = '';
|
|
|
+ }}
|
|
|
+ title='이미지 첨부'
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="alert-config__btn"
|
|
|
+ onClick={() => imageInputRef.current?.click()}
|
|
|
+ disabled={saving}
|
|
|
+ >
|
|
|
+ <FontAwesomeIcon icon={faUpload} />
|
|
|
+ 이미지 선택
|
|
|
+ </button>
|
|
|
+ {pendingFiles.image && (
|
|
|
+ <span className="alert-config__field-hint">{pendingFiles.image.name}</span>
|
|
|
+ )}
|
|
|
+ <span className="alert-config__field-hint">JPG, PNG, GIF (최대 20MB)</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 사운드 */}
|
|
|
+ <div className="alert-config__field">
|
|
|
+ <label htmlFor="config-enable-sound" className="alert-config__checkbox-label">
|
|
|
+ <Checkbox
|
|
|
+ id="config-enable-sound"
|
|
|
+ checked={form.enableSound}
|
|
|
+ onCheckedChange={v => onFormChange('enableSound', !!v)}
|
|
|
+ />
|
|
|
+ 사운드 사용
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ {form.enableSound && (
|
|
|
+ <div className="alert-config__media-upload">
|
|
|
+ {form.soundUrl && (
|
|
|
+ <div className="alert-config__media-preview alert-config__media-preview--audio">
|
|
|
+ <audio controls src={form.soundUrl} />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="alert-config__media-remove"
|
|
|
+ onClick={() => onFormChange('soundUrl', null)}
|
|
|
+ title="삭제"
|
|
|
+ >
|
|
|
+ <FontAwesomeIcon icon={faTrash} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <input
|
|
|
+ ref={soundInputRef}
|
|
|
+ type="file"
|
|
|
+ accept=".mp3,.ogg,.wav,.m4a"
|
|
|
+ className="alert-config__file-hidden"
|
|
|
+ onChange={e => {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (file) onFileSelect(file, 'sound');
|
|
|
+ e.target.value = '';
|
|
|
+ }}
|
|
|
+ title='사운드 첨부'
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="alert-config__btn"
|
|
|
+ onClick={() => soundInputRef.current?.click()}
|
|
|
+ disabled={saving}
|
|
|
+ >
|
|
|
+ <FontAwesomeIcon icon={faUpload} />
|
|
|
+ 사운드 선택
|
|
|
+ </button>
|
|
|
+ {pendingFiles.sound && (
|
|
|
+ <span className="alert-config__field-hint">{pendingFiles.sound.name}</span>
|
|
|
+ )}
|
|
|
+ <span className="alert-config__field-hint">MP3, OGG, WAV, M4A (최대 50MB)</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ {/* ── 하단 버튼 ────────────────────────────── */}
|
|
|
+ <div className="alert-config__form-footer flex-1 w-full sm:justify-end gap-2">
|
|
|
+ <button type="button" className="alert-config__btn flex-1 sm:flex-none" onClick={onCancel}>
|
|
|
+ 취소
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="alert-config__btn alert-config__btn--primary flex-1 sm:flex-none"
|
|
|
+ onClick={onSave}
|
|
|
+ disabled={saving}
|
|
|
+ >
|
|
|
+ {saving ? '저장 중...' : (editingItem ? '수정하기' : '등록하기')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+ );
|
|
|
+}
|