CrewWidgetFormPanel.tsx 13 KB

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