| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- '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>
- );
- }
|