CheerModal.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. 'use client';
  2. // D3 응원(Cheer) 모달 — 금액/메시지 입력 후 POST /api/posts/{postID}/cheers.
  3. // 규제 가드라인(d3 §⑧): 응원은 사후·자율·무대가. 글 열람 조건 아님.
  4. import { useCallback, useEffect, useState } from 'react';
  5. import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import type { WalletResponse, CheerSendResponse } from '@/types/reward';
  8. import { sumCash, sumToken } from '@/types/reward';
  9. const PRESET_AMOUNTS = [100, 500, 1000, 5000];
  10. const MAX_MESSAGE = 100;
  11. type Props = {
  12. postID: number;
  13. onClose: () => void;
  14. onDone: () => void;
  15. };
  16. export default function CheerModal({ postID, onClose, onDone }: Props)
  17. {
  18. const [amount, setAmount] = useState<string>('');
  19. const [message, setMessage] = useState<string>('');
  20. const [cash, setCash] = useState<number|null>(null);
  21. const [token, setToken] = useState<number|null>(null);
  22. const [busy, setBusy] = useState(false);
  23. const [error, setError] = useState<string|null>(null);
  24. // 지갑 가용 잔액 조회 (캐시/토큰 분리 표시 — 응원은 캐시→토큰 순 차감)
  25. useEffect(() => {
  26. let cancelled = false;
  27. (async () => {
  28. const res = await fetchApi<WalletResponse>('/api/wallet', { silent: true });
  29. if (cancelled) {
  30. return;
  31. }
  32. if (res.success && res.data) {
  33. setCash(sumCash(res.data.balances));
  34. setToken(sumToken(res.data.balances));
  35. }
  36. else {
  37. setCash(null);
  38. setToken(null);
  39. }
  40. })();
  41. return () => {
  42. cancelled = true;
  43. };
  44. }, []);
  45. const available = (cash ?? 0) + (token ?? 0);
  46. const submit = useCallback(async () => {
  47. setError(null);
  48. const numeric = Number(amount);
  49. if (!Number.isInteger(numeric) || numeric <= 0) {
  50. setError('1 이상의 정수 금액을 입력해 주세요.');
  51. return;
  52. }
  53. if (cash !== null && token !== null && numeric > available) {
  54. setError('가용 잔액이 부족합니다.');
  55. return;
  56. }
  57. setBusy(true);
  58. const res = await fetchApi<CheerSendResponse>(`/api/posts/${postID}/cheers`, {
  59. method: 'POST',
  60. body: { amount: numeric, message: message.trim() || null },
  61. silent: true
  62. });
  63. setBusy(false);
  64. if (res.success && res.data) {
  65. onDone();
  66. }
  67. else {
  68. setError(res.message || '응원에 실패했습니다.');
  69. }
  70. }, [amount, message, cash, token, available, postID, onDone]);
  71. return (
  72. <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
  73. <DialogContent>
  74. <DialogHeader>
  75. <DialogTitle>응원 보내기</DialogTitle>
  76. </DialogHeader>
  77. <div className='flex flex-col gap-4'>
  78. <div className='rounded-md bg-neutral-100 dark:bg-neutral-800 px-3 py-2 text-sm'>
  79. <div className='flex justify-between'>
  80. <span className='text-neutral-500'>가용 잔액</span>
  81. <strong>{cash === null ? '—' : available.toLocaleString()}</strong>
  82. </div>
  83. <div className='mt-1 flex justify-between text-xs text-neutral-500'>
  84. <span>캐시 {cash === null ? '—' : cash.toLocaleString()}</span>
  85. <span>토큰 {token === null ? '—' : token.toLocaleString()}</span>
  86. </div>
  87. </div>
  88. <div className='flex flex-wrap gap-2'>
  89. {PRESET_AMOUNTS.map((preset) => (
  90. <button
  91. key={preset}
  92. type='button'
  93. onClick={() => setAmount(String(preset))}
  94. className='px-3 py-1.5 rounded-md border border-neutral-300 dark:border-neutral-700 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800'
  95. >
  96. +{preset.toLocaleString()}
  97. </button>
  98. ))}
  99. </div>
  100. <div className='flex flex-col gap-1'>
  101. <label className='text-sm font-semibold' htmlFor='cheer-amount'>응원 금액</label>
  102. <input
  103. id='cheer-amount'
  104. type='number'
  105. min={1}
  106. step={1}
  107. inputMode='numeric'
  108. value={amount}
  109. onChange={(e) => setAmount(e.target.value)}
  110. placeholder='금액 입력'
  111. className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
  112. autoFocus
  113. />
  114. </div>
  115. <div className='flex flex-col gap-1'>
  116. <label className='text-sm font-semibold' htmlFor='cheer-message'>
  117. 응원 한마디 <span className='text-xs font-normal text-neutral-400'>(선택)</span>
  118. </label>
  119. <textarea
  120. id='cheer-message'
  121. value={message}
  122. onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE))}
  123. rows={2}
  124. maxLength={MAX_MESSAGE}
  125. placeholder='따뜻한 한마디를 남겨보세요'
  126. className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900 resize-none'
  127. />
  128. <div className='text-right text-xs text-neutral-400'>{message.length}/{MAX_MESSAGE}</div>
  129. </div>
  130. <p className='text-xs text-neutral-500'>
  131. * 응원은 자발적이며 글 열람 조건이 아닙니다. 캐시 → 토큰 순으로 차감되며 일부는 플랫폼 수수료로 사용됩니다.
  132. </p>
  133. {error && <p className='text-sm text-red-600' role='alert'>{error}</p>}
  134. <div className='flex justify-end gap-2'>
  135. <button
  136. type='button'
  137. onClick={onClose}
  138. disabled={busy}
  139. className='px-4 py-2 rounded-md border border-neutral-300 dark:border-neutral-700 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50'
  140. >
  141. 취소
  142. </button>
  143. <button
  144. type='button'
  145. onClick={submit}
  146. disabled={busy}
  147. className='px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-50'
  148. >
  149. {busy ? '전송 중...' : '응원하기'}
  150. </button>
  151. </div>
  152. </div>
  153. </DialogContent>
  154. </Dialog>
  155. );
  156. }