page.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { fetchApi } from '@/lib/utils/client';
  4. import { NotificationItem } from '@/types/notification';
  5. import { NotificationListResponse } from '@/types/response/notification/list';
  6. import { NotificationReadRequest } from '@/types/request/notification/read';
  7. const TYPE_LABELS: Record<number, string> = {
  8. 1: '후원 받음',
  9. 2: '후원 보냄',
  10. 10: '크루 초대',
  11. 11: '크루 시작',
  12. 12: '크루 종료',
  13. 13: '크루 후원',
  14. 20: '정산 승인',
  15. 21: '정산 거부',
  16. 30: '새 쪽지',
  17. 99: '시스템'
  18. };
  19. export default function NotificationPage() {
  20. const [notifications, setNotifications] = useState<NotificationItem[]>([]);
  21. const [total, setTotal] = useState(0);
  22. const [page, setPage] = useState(1);
  23. const [loading, setLoading] = useState(true);
  24. useEffect(() => {
  25. loadNotifications();
  26. }, [page]);
  27. const loadNotifications = async () => {
  28. setLoading(true);
  29. try {
  30. const res = await fetchApi<NotificationListResponse>(`/api/notification/list?pageNum=${page}&perPage=20`, { silent: true });
  31. if (res.data) {
  32. setNotifications(res.data.list || []);
  33. setTotal(res.data.total || 0);
  34. }
  35. } catch {}
  36. setLoading(false);
  37. };
  38. const handleRead = async (item: NotificationItem) => {
  39. if (!item.isRead) {
  40. await fetchApi('/api/notification/read', {
  41. method: 'POST',
  42. body: { notificationID: item.id } as NotificationReadRequest,
  43. silent: true
  44. });
  45. setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, isRead: true } : n));
  46. }
  47. if (item.actionUrl) {
  48. window.location.href = item.actionUrl;
  49. }
  50. };
  51. const handleReadAll = async () => {
  52. await fetchApi('/api/notification/read', {
  53. method: 'POST',
  54. body: {} as NotificationReadRequest,
  55. silent: true
  56. });
  57. setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
  58. };
  59. const formatDate = (dateStr: string) => {
  60. const d = new Date(dateStr);
  61. const now = new Date();
  62. const diff = Math.floor((now.getTime() - d.getTime()) / 60000);
  63. if (diff < 1) { return '방금'; }
  64. if (diff < 60) { return `${diff}분 전`; }
  65. if (diff < 1440) { return `${Math.floor(diff / 60)}시간 전`; }
  66. return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
  67. };
  68. return (
  69. <div className="container mx-auto max-w-2xl p-4">
  70. <div className="flex items-center justify-between mb-4">
  71. <h1 className="text-xl font-bold">알림</h1>
  72. <button type="button" onClick={handleReadAll} className="text-sm text-blue-500 hover:underline">모두 읽음</button>
  73. </div>
  74. {loading && <div className="text-center text-gray-500 py-10">로딩 중...</div>}
  75. {!loading && notifications.length === 0 && (
  76. <div className="text-center text-gray-500 py-10">알림이 없습니다</div>
  77. )}
  78. <div className="flex flex-col gap-2">
  79. {notifications.map(n => (
  80. <div key={n.id} onClick={() => handleRead(n)} className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition-colors ${n.isRead ? 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700' : 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800'}`}>
  81. {n.imageUrl ? (
  82. <img src={n.imageUrl} alt="" className="w-10 h-10 rounded-full object-cover flex-shrink-0" />
  83. ) : (
  84. <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 text-lg">🔔</div>
  85. )}
  86. <div className="flex-1 overflow-hidden">
  87. <div className="flex items-center gap-2 mb-1">
  88. <span className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-gray-600 dark:text-gray-400">
  89. {TYPE_LABELS[n.type] || '알림'}
  90. </span>
  91. <span className="text-xs text-gray-400">{formatDate(n.createdAt)}</span>
  92. </div>
  93. <div className={`text-sm ${n.isRead ? 'font-normal' : 'font-semibold'}`}>{n.title}</div>
  94. <div className="text-xs text-gray-500 mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap">{n.message}</div>
  95. </div>
  96. {!n.isRead && <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0 mt-2" />}
  97. </div>
  98. ))}
  99. </div>
  100. {total > 20 && (
  101. <div className="flex justify-center gap-2 mt-4">
  102. <button type="button" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="px-3 py-1 text-sm border rounded disabled:opacity-50">이전</button>
  103. <span className="px-3 py-1 text-sm">{page} / {Math.ceil(total / 20)}</span>
  104. <button type="button" onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(total / 20)} className="px-3 py-1 text-sm border rounded disabled:opacity-50">다음</button>
  105. </div>
  106. )}
  107. </div>
  108. );
  109. }