CrewWidgetFormPanel.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import { useStudioContext } from '@/app/studio/context';
  6. import { useCrewWidgetConfigContext } from '../context';
  7. import { CREW_WIDGET_THEMES, CREW_PERIODS, FONT_FAMILIES } from '../constants';
  8. import { type FormState, createEmptyForm, formatInput, parseInput } from '../types';
  9. import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
  10. import { Checkbox } from '@/components/ui/checkbox';
  11. /** 색상 입력 (color picker + hex text) */
  12. function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  13. return (
  14. <div className="crew-widget-form__color-field">
  15. <input
  16. type="color"
  17. className="crew-widget-form__color-picker"
  18. value={value}
  19. onChange={e => onChange(e.target.value)}
  20. />
  21. <input
  22. type="text"
  23. className="crew-widget-form__input crew-widget-form__input--color-text"
  24. value={value}
  25. onChange={e => onChange(e.target.value)}
  26. maxLength={7}
  27. />
  28. </div>
  29. );
  30. }
  31. type Props = {
  32. editItem?: CrewWidgetConfigItem;
  33. form?: FormState;
  34. onFormChange?: (form: FormState) => void;
  35. onSaved?: () => void;
  36. onCancel?: () => void;
  37. channelID?: number;
  38. saving?: boolean;
  39. setSaving?: (v: boolean) => void;
  40. };
  41. export default function CrewWidgetFormPanel({ editItem, form: externalForm, onFormChange, onSaved, onCancel, channelID: directChannelID, saving: directSaving, setSaving: directSetSaving }: Props)
  42. {
  43. const router = useRouter();
  44. const studio = useStudioContext();
  45. const channelID = directChannelID ?? studio.channelID;
  46. // context 사용 가능 여부에 따라 분기
  47. let ctxSaving = false;
  48. let ctxSetSaving: (v: boolean) => void = () => {};
  49. let ctxFetchList: () => void = () => {};
  50. try {
  51. const ctx = useCrewWidgetConfigContext();
  52. ctxSaving = ctx.saving;
  53. ctxSetSaving = ctx.setSaving;
  54. ctxFetchList = ctx.fetchList;
  55. } catch {
  56. }
  57. const saving = directSaving ?? ctxSaving;
  58. const setSaving = directSetSaving ?? ctxSetSaving;
  59. const fetchList = ctxFetchList;
  60. const [internalForm, setInternalForm] = useState<FormState>(createEmptyForm());
  61. const form = externalForm ?? internalForm;
  62. useEffect(() => {
  63. if (!editItem) {
  64. return;
  65. }
  66. const data: FormState = {
  67. title: editItem.title,
  68. theme: editItem.theme,
  69. period: editItem.period,
  70. startAt: editItem.startAt,
  71. endAt: editItem.endAt,
  72. maxDisplayCount: editItem.maxDisplayCount,
  73. isShowAmount: editItem.isShowAmount,
  74. isShowDonationCount: editItem.isShowDonationCount,
  75. isShowContributionRate: editItem.isShowContributionRate,
  76. isShowMemberIcon: editItem.isShowMemberIcon,
  77. isActive: editItem.isActive,
  78. bgColor: editItem.bgColor,
  79. titleFontFamily: editItem.titleFontFamily,
  80. titleFontSizePx: editItem.titleFontSizePx,
  81. titleFontColor: editItem.titleFontColor,
  82. rank1FontFamily: editItem.rank1FontFamily,
  83. rank1FontSizePx: editItem.rank1FontSizePx,
  84. rank1FontColor: editItem.rank1FontColor,
  85. rank2FontFamily: editItem.rank2FontFamily,
  86. rank2FontSizePx: editItem.rank2FontSizePx,
  87. rank2FontColor: editItem.rank2FontColor,
  88. rank3FontFamily: editItem.rank3FontFamily,
  89. rank3FontSizePx: editItem.rank3FontSizePx,
  90. rank3FontColor: editItem.rank3FontColor,
  91. rowFontFamily: editItem.rowFontFamily,
  92. rowFontSizePx: editItem.rowFontSizePx,
  93. rowFontColor: editItem.rowFontColor
  94. };
  95. if (onFormChange) {
  96. onFormChange(data);
  97. } else {
  98. setInternalForm(data);
  99. }
  100. }, [editItem]);
  101. const set = (key: keyof FormState, value: FormState[keyof FormState]) => {
  102. const updater = (f: FormState): FormState => ({ ...f, [key]: value });
  103. if (onFormChange) {
  104. onFormChange(updater(form));
  105. } else {
  106. setInternalForm(updater);
  107. }
  108. };
  109. const handleSave = async () => {
  110. if (!form.title.trim()) {
  111. alert('제목을 입력해 주세요.');
  112. return;
  113. }
  114. if (form.period === 5) {
  115. if (!form.startAt || !form.endAt) {
  116. alert('사용자 지정 기간을 입력해 주세요.');
  117. return;
  118. }
  119. }
  120. setSaving(true);
  121. try {
  122. await fetchApi('/api/studio/crew/widget/config', {
  123. method: 'POST',
  124. body: { ...form, channelID, id: editItem?.id ?? undefined }
  125. });
  126. fetchList();
  127. if (onSaved) {
  128. onSaved();
  129. } else {
  130. router.push('/studio/donation/crew/widget/list');
  131. }
  132. } catch (err: unknown) {
  133. alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
  134. } finally {
  135. setSaving(false);
  136. }
  137. };
  138. const renderFontFields = (prefix: string) =>
  139. {
  140. const familyKey = `${prefix}FontFamily` as keyof FormState;
  141. const sizeKey = `${prefix}FontSizePx` as keyof FormState;
  142. const colorKey = `${prefix}FontColor` as keyof FormState;
  143. return (
  144. <>
  145. <div className="crew-widget-form__field">
  146. <label className="crew-widget-form__field-label">글꼴</label>
  147. <select
  148. className="crew-widget-form__select"
  149. aria-label="글꼴"
  150. value={(form[familyKey] as string) ?? ''}
  151. onChange={e => set(familyKey, e.target.value || null)}
  152. >
  153. {FONT_FAMILIES.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
  154. </select>
  155. </div>
  156. <div className="crew-widget-form__row">
  157. <div className="crew-widget-form__field">
  158. <label className="crew-widget-form__field-label">크기(px)</label>
  159. <input
  160. type="number"
  161. className="crew-widget-form__input"
  162. min={10}
  163. max={48}
  164. value={form[sizeKey] as number}
  165. onChange={e => set(sizeKey, Number(e.target.value))}
  166. />
  167. </div>
  168. <div className="crew-widget-form__field">
  169. <label className="crew-widget-form__field-label">색상</label>
  170. <ColorInput
  171. value={form[colorKey] as string}
  172. onChange={v => set(colorKey, v)}
  173. />
  174. </div>
  175. </div>
  176. </>
  177. );
  178. };
  179. const renderFontDetails = (label: string, prefix: string) => {
  180. return (
  181. <details className="crew-widget-form__details">
  182. <summary className="crew-widget-form__details-summary">{label} 폰트 설정</summary>
  183. <div className="crew-widget-form__details-body">
  184. {renderFontFields(prefix)}
  185. </div>
  186. </details>
  187. );
  188. };
  189. return (
  190. <main className="crew-widget-form">
  191. {/* 기본 설정 */}
  192. <details className="crew-widget-form__section" open>
  193. <summary className="crew-widget-form__section-title">기본 설정</summary>
  194. <div className="crew-widget-form__section-body">
  195. <div className="crew-widget-form__field">
  196. <label className="crew-widget-form__field-label"><span className="text-destructive mr-0.5">*</span> 제목</label>
  197. <input
  198. type="text"
  199. className="crew-widget-form__input"
  200. value={form.title}
  201. onChange={e => set('title', e.target.value)}
  202. maxLength={300}
  203. />
  204. </div>
  205. <div className="crew-widget-form__row">
  206. <div className="crew-widget-form__field">
  207. <label className="crew-widget-form__field-label">테마</label>
  208. <select
  209. className="crew-widget-form__select"
  210. aria-label="테마"
  211. value={form.theme}
  212. onChange={e => set('theme', Number(e.target.value))}
  213. >
  214. {CREW_WIDGET_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
  215. </select>
  216. </div>
  217. <div className="crew-widget-form__field">
  218. <label className="crew-widget-form__field-label">기간</label>
  219. <select
  220. className="crew-widget-form__select"
  221. aria-label="기간"
  222. value={form.period}
  223. onChange={e => set('period', Number(e.target.value))}
  224. >
  225. {CREW_PERIODS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
  226. </select>
  227. </div>
  228. </div>
  229. {form.period === 5 && (
  230. <div className="crew-widget-form__row">
  231. <div className="crew-widget-form__field">
  232. <label className="crew-widget-form__field-label">시작</label>
  233. <input
  234. type="text"
  235. className="crew-widget-form__input"
  236. placeholder="2026.04.12 14:00"
  237. maxLength={16}
  238. value={form.startAt ? formatInput(new Date(form.startAt)) : ''}
  239. onChange={e => set('startAt', parseInput(e.target.value) || null)}
  240. />
  241. </div>
  242. <div className="crew-widget-form__field">
  243. <label className="crew-widget-form__field-label">종료</label>
  244. <input
  245. type="text"
  246. className="crew-widget-form__input"
  247. placeholder="2026.04.13 14:00"
  248. maxLength={16}
  249. value={form.endAt ? formatInput(new Date(form.endAt)) : ''}
  250. onChange={e => set('endAt', parseInput(e.target.value) || null)}
  251. />
  252. </div>
  253. </div>
  254. )}
  255. <div className="crew-widget-form__row">
  256. <div className="crew-widget-form__field">
  257. <label className="crew-widget-form__field-label">최대 표시 수</label>
  258. <input
  259. type="number"
  260. className="crew-widget-form__input"
  261. min={1}
  262. max={20}
  263. value={form.maxDisplayCount}
  264. onChange={e => set('maxDisplayCount', Number(e.target.value))}
  265. />
  266. </div>
  267. <div className="crew-widget-form__field">
  268. <label className="crew-widget-form__field-label">배경 색상</label>
  269. <ColorInput
  270. value={form.bgColor}
  271. onChange={v => set('bgColor', v)}
  272. />
  273. </div>
  274. </div>
  275. </div>
  276. </details>
  277. {/* 표시 옵션 */}
  278. <details className="crew-widget-form__section" open>
  279. <summary className="crew-widget-form__section-title">표시 옵션</summary>
  280. <div className="crew-widget-form__section-body">
  281. <div className="crew-widget-form__field">
  282. <label className="crew-widget-form__checkbox-label">
  283. <Checkbox checked={form.isShowAmount} onCheckedChange={v => set('isShowAmount', !!v)} />
  284. 후원 금액 표시
  285. </label>
  286. </div>
  287. <div className="crew-widget-form__field">
  288. <label className="crew-widget-form__checkbox-label">
  289. <Checkbox checked={form.isShowDonationCount} onCheckedChange={v => set('isShowDonationCount', !!v)} />
  290. 후원 건수 표시
  291. </label>
  292. </div>
  293. <div className="crew-widget-form__field">
  294. <label className="crew-widget-form__checkbox-label">
  295. <Checkbox checked={form.isShowContributionRate} onCheckedChange={v => set('isShowContributionRate', !!v)} />
  296. 기여율 표시
  297. </label>
  298. </div>
  299. <div className="crew-widget-form__field">
  300. <label className="crew-widget-form__checkbox-label">
  301. <Checkbox checked={form.isShowMemberIcon} onCheckedChange={v => set('isShowMemberIcon', !!v)} />
  302. 크루원 아이콘 표시
  303. </label>
  304. </div>
  305. <div className="crew-widget-form__field">
  306. <label className="crew-widget-form__checkbox-label">
  307. <Checkbox checked={form.isActive} onCheckedChange={v => set('isActive', !!v)} />
  308. 활성화
  309. </label>
  310. </div>
  311. </div>
  312. </details>
  313. {/* 제목 폰트 */}
  314. <details className="crew-widget-form__section" open>
  315. <summary className="crew-widget-form__section-title">제목 폰트</summary>
  316. <div className="crew-widget-form__section-body">
  317. {renderFontFields('title')}
  318. </div>
  319. </details>
  320. {/* 순위별 폰트 */}
  321. <details className="crew-widget-form__section" open>
  322. <summary className="crew-widget-form__section-title">순위별 폰트</summary>
  323. <div className="crew-widget-form__section-body">
  324. {renderFontDetails('1위', 'rank1')}
  325. {renderFontDetails('2위', 'rank2')}
  326. {renderFontDetails('3위', 'rank3')}
  327. {renderFontDetails('일반', 'row')}
  328. </div>
  329. </details>
  330. {/* 버튼 */}
  331. <div className="crew-widget-form__footer flex-1 w-full sm:justify-end gap-2">
  332. <button type="button" className="crew-widget-form__btn flex-1 sm:flex-none" onClick={() => onCancel ? onCancel() : router.push('/studio/donation/crew/widget/list')}>취소</button>
  333. <button type="button" className="crew-widget-form__btn crew-widget-form__btn--primary flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>
  334. {saving ? '저장 중...' : '저장'}
  335. </button>
  336. </div>
  337. </main>
  338. );
  339. }