Cheer.tsx 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. 'use client';
  2. // D3 응원(Cheer) 섹션 — 글 상세 하단. 누적 응원 배지 + 최근 목록 + 응원 버튼(+모달).
  3. // 목록 조회는 익명 허용(GET). 발신은 로그인 필요 + 본인 글에는 버튼 숨김.
  4. import { useCallback, useEffect, useState } from 'react';
  5. import { Heart } from 'lucide-react';
  6. import { fetchApi, getDateTime } from '@/lib/utils/client';
  7. import useAuth from '@/hooks/useAuth';
  8. import type { CheerListResponse } from '@/types/reward';
  9. import CheerModal from './CheerModal';
  10. type Props = {
  11. postID: number;
  12. authorMemberID: number|null;
  13. };
  14. export default function Cheer({ postID, authorMemberID }: Props)
  15. {
  16. const { member, loginCheck } = useAuth();
  17. const [data, setData] = useState<CheerListResponse|null>(null);
  18. const [open, setOpen] = useState(false);
  19. const [expanded, setExpanded] = useState(false);
  20. const isOwner = member?.id != null && member.id === authorMemberID;
  21. const load = useCallback(async () => {
  22. const res = await fetchApi<CheerListResponse>(`/api/posts/${postID}/cheers?take=20`, { silent: true });
  23. if (res.success && res.data) {
  24. setData(res.data);
  25. }
  26. }, [postID]);
  27. useEffect(() => {
  28. load();
  29. }, [load]);
  30. const handleOpen = useCallback(() => {
  31. if (!loginCheck()) {
  32. return;
  33. }
  34. setOpen(true);
  35. }, [loginCheck]);
  36. const handleDone = useCallback(() => {
  37. setOpen(false);
  38. load();
  39. }, [load]);
  40. const total = data?.totalAmount ?? 0;
  41. const count = data?.count ?? 0;
  42. const recent = data?.recent ?? [];
  43. const shown = expanded ? recent : recent.slice(0, 5);
  44. return (
  45. <section className='my-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4'>
  46. <div className='flex flex-wrap items-center justify-between gap-3'>
  47. <div className='flex items-center gap-2'>
  48. <Heart className='size-5 text-red-500' aria-hidden />
  49. <span className='font-semibold'>응원</span>
  50. {count > 0 && (
  51. <span className='inline-flex items-center gap-1 rounded-full bg-red-50 dark:bg-red-950/40 px-2.5 py-1 text-sm text-red-600 dark:text-red-400 font-semibold'>
  52. {total.toLocaleString()}
  53. <span className='text-xs font-normal text-red-400'>({count.toLocaleString()}건)</span>
  54. </span>
  55. )}
  56. </div>
  57. {!isOwner && (
  58. <button
  59. type='button'
  60. onClick={handleOpen}
  61. className='inline-flex items-center gap-1.5 px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700'
  62. >
  63. <Heart className='size-4' aria-hidden />
  64. 응원하기
  65. </button>
  66. )}
  67. </div>
  68. {isOwner && count === 0 && (
  69. <p className='mt-2 text-xs text-neutral-400'>내 글에는 응원할 수 없습니다.</p>
  70. )}
  71. {recent.length > 0 && (
  72. <ul className='mt-3 flex flex-col gap-2'>
  73. {shown.map((c) => (
  74. <li key={c.id} className='flex items-start gap-2 text-sm'>
  75. <span className='inline-flex shrink-0 items-center rounded bg-red-50 dark:bg-red-950/40 px-1.5 py-0.5 text-xs font-semibold text-red-600 dark:text-red-400'>
  76. {c.amount.toLocaleString()}
  77. </span>
  78. <div className='min-w-0 flex-1'>
  79. <span className='font-semibold'>{c.fromNickname ?? '익명'}</span>
  80. {c.message && <span className='ml-1.5 text-neutral-600 dark:text-neutral-300 break-words'>{c.message}</span>}
  81. </div>
  82. <span className='shrink-0 text-xs text-neutral-400'>{getDateTime(c.createdAt)}</span>
  83. </li>
  84. ))}
  85. </ul>
  86. )}
  87. {recent.length > 5 && (
  88. <button
  89. type='button'
  90. onClick={() => setExpanded((prev) => !prev)}
  91. className='mt-2 text-xs text-neutral-500 hover:underline'
  92. >
  93. {expanded ? '접기' : `최근 응원 더보기 (${recent.length})`}
  94. </button>
  95. )}
  96. {open && (
  97. <CheerModal postID={postID} onClose={() => setOpen(false)} onDone={handleDone} />
  98. )}
  99. </section>
  100. );
  101. }