CrewWidgetPreviewPanel.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. 'use client';
  2. import { useState, type CSSProperties } from 'react';
  3. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  4. import { faPlay } from '@fortawesome/free-solid-svg-icons';
  5. import {
  6. DONATION_AMOUNT_MIN, DONATION_AMOUNT_MAX, DONATION_AMOUNT_STEP, DONATION_AMOUNT_DEFAULT,
  7. ALERT_NAME_MAX_LENGTH, PREVIEW_DEFAULT_NAME
  8. } from '@/constants/donation';
  9. import { MOCK_CREW_RANKING } from '../constants';
  10. import type { FormState } from '../types';
  11. type Props = {
  12. form: FormState;
  13. };
  14. type SimulatedDonation = {
  15. name: string;
  16. totalAmount: number;
  17. donationCount: number;
  18. };
  19. const THEME_NAMES = ['basic', 'dark', 'minimal'];
  20. export default function CrewWidgetPreviewPanel({ form }: Props)
  21. {
  22. const [previewName, setPreviewName] = useState(PREVIEW_DEFAULT_NAME);
  23. const [previewAmount, setPreviewAmount] = useState(DONATION_AMOUNT_DEFAULT);
  24. const [simulatedDonations, setSimulatedDonations] = useState<SimulatedDonation[]>([]);
  25. // 시뮬레이션 데이터 있으면 contributionRate 자동 계산, 없으면 mock
  26. const visibleItems = simulatedDonations.length > 0
  27. ? (() => {
  28. const sorted = simulatedDonations.slice().sort((a, b) => b.totalAmount - a.totalAmount);
  29. const total = sorted.reduce((sum, d) => sum + d.totalAmount, 0) || 1;
  30. return sorted.slice(0, form.maxDisplayCount).map((d, i) => ({
  31. rank: i + 1,
  32. nickname: d.name,
  33. totalAmount: d.totalAmount,
  34. donationCount: d.donationCount,
  35. contributionRate: (d.totalAmount / total) * 100
  36. }));
  37. })()
  38. : MOCK_CREW_RANKING.slice(0, form.maxDisplayCount);
  39. const themeName = THEME_NAMES[form.theme] ?? 'basic';
  40. const getFontStyle = (rank: number): CSSProperties => {
  41. if (rank === 1) {
  42. return {
  43. '--row-font-family': form.rank1FontFamily || 'inherit',
  44. '--row-font-size': `${form.rank1FontSizePx}px`,
  45. '--row-font-color': form.rank1FontColor
  46. } as CSSProperties;
  47. }
  48. if (rank === 2) {
  49. return {
  50. '--row-font-family': form.rank2FontFamily || 'inherit',
  51. '--row-font-size': `${form.rank2FontSizePx}px`,
  52. '--row-font-color': form.rank2FontColor
  53. } as CSSProperties;
  54. }
  55. if (rank === 3) {
  56. return {
  57. '--row-font-family': form.rank3FontFamily || 'inherit',
  58. '--row-font-size': `${form.rank3FontSizePx}px`,
  59. '--row-font-color': form.rank3FontColor
  60. } as CSSProperties;
  61. }
  62. return {
  63. '--row-font-family': form.rowFontFamily || 'inherit',
  64. '--row-font-size': `${form.rowFontSizePx}px`,
  65. '--row-font-color': form.rowFontColor
  66. } as CSSProperties;
  67. };
  68. const getBadgeClass = (rank: number) => {
  69. if (rank <= 3) {
  70. return `crew-widget-preview__badge--${rank}`;
  71. }
  72. return 'crew-widget-preview__badge--default';
  73. };
  74. const bodyStyle = { '--preview-bg': form.bgColor } as CSSProperties;
  75. const titleStyle = {
  76. '--preview-title-font': form.titleFontFamily || 'inherit',
  77. '--preview-title-size': `${form.titleFontSizePx}px`,
  78. '--preview-title-color': form.titleFontColor
  79. } as CSSProperties;
  80. const handlePreview = () => {
  81. const name = (previewName || PREVIEW_DEFAULT_NAME).trim();
  82. const amount = previewAmount || DONATION_AMOUNT_DEFAULT;
  83. setSimulatedDonations(prev => {
  84. const existing = prev.find(d => d.name === name);
  85. if (existing) {
  86. return prev.map(d => d.name === name
  87. ? { ...d, totalAmount: d.totalAmount + amount, donationCount: d.donationCount + 1 }
  88. : d
  89. );
  90. }
  91. return [...prev, { name, totalAmount: amount, donationCount: 1 }];
  92. });
  93. };
  94. const handleReset = () => {
  95. setSimulatedDonations([]);
  96. };
  97. const totalSimulatedAmount = simulatedDonations.reduce((sum, d) => sum + d.totalAmount, 0);
  98. return (
  99. <aside className="crew-widget-preview">
  100. <div className="crew-widget-preview__widget">
  101. <div className="crew-widget-preview__widget-label">미리보기</div>
  102. <div
  103. className={`crew-widget-preview__widget-body crew-widget-preview__widget-body--${themeName}`}
  104. style={bodyStyle}
  105. >
  106. {/* 위젯 제목 */}
  107. <div className="crew-widget-preview__title" style={titleStyle}>
  108. {form.title || '크루 순위'}
  109. </div>
  110. {/* 컬럼 헤더 */}
  111. <div className="crew-widget-preview__columns">
  112. <span className="crew-widget-preview__col-rank">순위</span>
  113. {form.isShowMemberIcon && <span className="crew-widget-preview__col-icon" />}
  114. <span className="crew-widget-preview__col-name">이름</span>
  115. {form.isShowContributionRate && <span className="crew-widget-preview__col-rate">기여도</span>}
  116. {form.isShowAmount && <span className="crew-widget-preview__col-amount">후원 점수</span>}
  117. {form.isShowDonationCount && <span className="crew-widget-preview__col-count">건수</span>}
  118. </div>
  119. {/* 순위 리스트 */}
  120. <div className="crew-widget-preview__list">
  121. {visibleItems.map(item => {
  122. const style = getFontStyle(item.rank);
  123. return (
  124. <div
  125. key={item.rank}
  126. className={`crew-widget-preview__item${item.rank <= 3 ? ` crew-widget-preview__item--${item.rank}` : ''}`}
  127. style={style}
  128. >
  129. <span className={`crew-widget-preview__badge ${getBadgeClass(item.rank)}`}>
  130. {item.rank}
  131. </span>
  132. {form.isShowMemberIcon && <span className="crew-widget-preview__member-icon" />}
  133. <span className="crew-widget-preview__name">{item.nickname}</span>
  134. {form.isShowContributionRate && (
  135. <span className="crew-widget-preview__rate">{item.contributionRate.toFixed(1)}%</span>
  136. )}
  137. {form.isShowAmount && (
  138. <span className="crew-widget-preview__amount">{item.totalAmount.toLocaleString()}원</span>
  139. )}
  140. {form.isShowDonationCount && (
  141. <span className="crew-widget-preview__count">{item.donationCount}건</span>
  142. )}
  143. </div>
  144. );
  145. })}
  146. {visibleItems.length === 0 && (
  147. <div className="crew-widget-preview__empty">순위 데이터 없음</div>
  148. )}
  149. </div>
  150. </div>
  151. </div>
  152. <fieldset className="crew-widget-preview__fieldset">
  153. <legend className="crew-widget-preview__legend">미리보기</legend>
  154. <div className="crew-widget-preview__preview-form">
  155. <div className="crew-widget-preview__field">
  156. <label htmlFor="crew-preview-amount" className="crew-widget-preview__field-label">후원 금액 (원)</label>
  157. <input
  158. id="crew-preview-amount"
  159. type="number"
  160. className="crew-widget-preview__input"
  161. min={DONATION_AMOUNT_MIN}
  162. max={DONATION_AMOUNT_MAX}
  163. step={DONATION_AMOUNT_STEP}
  164. value={previewAmount}
  165. onChange={e => setPreviewAmount(parseInt(e.target.value) || DONATION_AMOUNT_DEFAULT)}
  166. />
  167. </div>
  168. <div className="crew-widget-preview__field">
  169. <label htmlFor="crew-preview-name" className="crew-widget-preview__field-label">이름</label>
  170. <input
  171. id="crew-preview-name"
  172. type="text"
  173. className="crew-widget-preview__input"
  174. maxLength={ALERT_NAME_MAX_LENGTH}
  175. value={previewName}
  176. onChange={e => setPreviewName(e.target.value)}
  177. placeholder="후원자 이름"
  178. />
  179. </div>
  180. <div className="crew-widget-preview__preview-actions">
  181. <button
  182. type="button"
  183. className="crew-widget-preview__btn crew-widget-preview__btn--primary crew-widget-preview__preview-btn"
  184. onClick={handlePreview}
  185. >
  186. <FontAwesomeIcon icon={faPlay} />
  187. 미리보기
  188. </button>
  189. <button
  190. type="button"
  191. className="crew-widget-preview__btn"
  192. onClick={handleReset}
  193. disabled={simulatedDonations.length === 0}
  194. >
  195. 초기화
  196. </button>
  197. </div>
  198. {simulatedDonations.length > 0 && (
  199. <p className="crew-widget-preview__field-hint">
  200. 후원자 {simulatedDonations.length}명 (총 {totalSimulatedAmount.toLocaleString()}원)
  201. </p>
  202. )}
  203. </div>
  204. </fieldset>
  205. </aside>
  206. );
  207. }