RankPreviewPanel.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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 type { FormState } from '../types';
  10. import { MOCK_RANKING } from '../constants';
  11. type Props = {
  12. form: FormState;
  13. };
  14. type SimulatedDonation = {
  15. name: string;
  16. totalAmount: number;
  17. donationCount: number;
  18. };
  19. /** 순위별 폰트 스타일 반환 (1~3등은 커스텀, 나머지는 기본) */
  20. const getRankStyle = (rank: number, form: FormState): CSSProperties => {
  21. if (rank === 1) {
  22. return {
  23. '--row-font-family': form.rank1FontFamily || 'inherit',
  24. '--row-font-size': `${form.rank1FontSizePx}px`,
  25. '--row-font-color': form.rank1FontColor
  26. } as CSSProperties;
  27. }
  28. if (rank === 2) {
  29. return {
  30. '--row-font-family': form.rank2FontFamily || 'inherit',
  31. '--row-font-size': `${form.rank2FontSizePx}px`,
  32. '--row-font-color': form.rank2FontColor
  33. } as CSSProperties;
  34. }
  35. if (rank === 3) {
  36. return {
  37. '--row-font-family': form.rank3FontFamily || 'inherit',
  38. '--row-font-size': `${form.rank3FontSizePx}px`,
  39. '--row-font-color': form.rank3FontColor
  40. } as CSSProperties;
  41. }
  42. return {};
  43. };
  44. export default function RankPreviewPanel({ form }: Props) {
  45. const [previewName, setPreviewName] = useState(PREVIEW_DEFAULT_NAME);
  46. const [previewAmount, setPreviewAmount] = useState(DONATION_AMOUNT_DEFAULT);
  47. const [simulatedDonations, setSimulatedDonations] = useState<SimulatedDonation[]>([]);
  48. // 시뮬레이션 데이터 있으면 그것 사용, 없으면 mock 데이터
  49. const visibleRanking = simulatedDonations.length > 0
  50. ? simulatedDonations
  51. .slice()
  52. .sort((a, b) => b.totalAmount - a.totalAmount)
  53. .slice(0, form.maxRankCount)
  54. .map((d, i) => ({
  55. rank: i + 1,
  56. sponsorName: d.name,
  57. totalAmount: d.totalAmount,
  58. donationCount: d.donationCount
  59. }))
  60. : MOCK_RANKING.slice(0, form.maxRankCount);
  61. const titleStyle = {
  62. '--preview-title-font': form.titleFontFamily || 'inherit',
  63. '--preview-title-size': `${form.titleFontSizePx}px`,
  64. '--preview-title-color': form.titleFontColor
  65. } as CSSProperties;
  66. const handlePreview = () => {
  67. const name = (previewName || PREVIEW_DEFAULT_NAME).trim();
  68. const amount = previewAmount || DONATION_AMOUNT_DEFAULT;
  69. setSimulatedDonations(prev => {
  70. const existing = prev.find(d => d.name === name);
  71. if (existing) {
  72. return prev.map(d => d.name === name
  73. ? { ...d, totalAmount: d.totalAmount + amount, donationCount: d.donationCount + 1 }
  74. : d
  75. );
  76. }
  77. return [...prev, { name, totalAmount: amount, donationCount: 1 }];
  78. });
  79. };
  80. const handleReset = () => {
  81. setSimulatedDonations([]);
  82. };
  83. const totalSimulatedAmount = simulatedDonations.reduce((sum, d) => sum + d.totalAmount, 0);
  84. return (
  85. <aside className="rank-config__preview-panel">
  86. <div className="rank-config__widget">
  87. <div className="rank-config__widget-label">미리보기</div>
  88. <div className={`rank-config__widget-body rank-config__widget-body--${['basic', 'dark', 'minimal'][form.theme] ?? 'basic'}`}>
  89. <div className="rank-preview">
  90. <div className="rank-preview__title" style={titleStyle}>
  91. {form.title || '후원 순위'}
  92. </div>
  93. <div className="rank-preview__list">
  94. {visibleRanking.map(item => {
  95. const badgeClass = item.rank <= 3 ? `rank-preview__badge--${item.rank}` : 'rank-preview__badge--default';
  96. const itemStyle = getRankStyle(item.rank, form);
  97. return (
  98. <div key={item.rank} className={`rank-preview__item rank-preview__item--${item.rank}`} style={itemStyle}>
  99. <div className={`rank-preview__badge ${badgeClass}`}>{item.rank}</div>
  100. {form.isShowMemberIcon && (
  101. <div className="rank-preview__member-icon" />
  102. )}
  103. {form.isShowGradeIcon && (
  104. <div className="rank-preview__grade-icon" />
  105. )}
  106. <div className="rank-preview__name">
  107. {item.sponsorName}
  108. </div>
  109. <div className="rank-preview__meta">
  110. {form.isShowAmount && (
  111. <span className="rank-preview__amount">
  112. {item.totalAmount.toLocaleString()}원
  113. </span>
  114. )}
  115. {form.isShowDonationCount && (
  116. <span className="rank-preview__count">
  117. {item.donationCount}회
  118. </span>
  119. )}
  120. </div>
  121. </div>
  122. );
  123. })}
  124. {visibleRanking.length === 0 && (
  125. <div className="rank-preview__empty">순위 데이터 없음</div>
  126. )}
  127. </div>
  128. </div>
  129. </div>
  130. </div>
  131. <fieldset className="rank-config__fieldset">
  132. <legend className="rank-config__legend">미리보기</legend>
  133. <div className="rank-config__preview-form">
  134. <div className="rank-config__field">
  135. <label htmlFor="rank-preview-amount" className="rank-config__field-label">후원 금액 (원)</label>
  136. <input
  137. id="rank-preview-amount"
  138. type="number"
  139. className="rank-config__input"
  140. min={DONATION_AMOUNT_MIN}
  141. max={DONATION_AMOUNT_MAX}
  142. step={DONATION_AMOUNT_STEP}
  143. value={previewAmount}
  144. onChange={e => setPreviewAmount(parseInt(e.target.value) || DONATION_AMOUNT_DEFAULT)}
  145. />
  146. </div>
  147. <div className="rank-config__field">
  148. <label htmlFor="rank-preview-name" className="rank-config__field-label">이름</label>
  149. <input
  150. id="rank-preview-name"
  151. type="text"
  152. className="rank-config__input"
  153. maxLength={ALERT_NAME_MAX_LENGTH}
  154. value={previewName}
  155. onChange={e => setPreviewName(e.target.value)}
  156. placeholder="후원자 이름"
  157. />
  158. </div>
  159. <div className="rank-config__preview-actions">
  160. <button
  161. type="button"
  162. className="rank-config__btn rank-config__btn--primary rank-config__preview-btn"
  163. onClick={handlePreview}
  164. >
  165. <FontAwesomeIcon icon={faPlay} />
  166. 미리보기
  167. </button>
  168. <button
  169. type="button"
  170. className="rank-config__btn"
  171. onClick={handleReset}
  172. disabled={simulatedDonations.length === 0}
  173. >
  174. 초기화
  175. </button>
  176. </div>
  177. {simulatedDonations.length > 0 && (
  178. <p className="rank-config__field-hint">
  179. 후원자 {simulatedDonations.length}명 (총 {totalSimulatedAmount.toLocaleString()}원)
  180. </p>
  181. )}
  182. </div>
  183. </fieldset>
  184. </aside>
  185. );
  186. }