- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
import { useAuthStore } from '@/lib/store';
|
|
import { notificationApi, Notification } from '@/lib/api';
|
|
|
|
export default function NotificationsPage() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const { user } = useAuthStore();
|
|
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
|
|
|
const pageSize = 20;
|
|
|
|
// Redirect if not logged in
|
|
useEffect(() => {
|
|
if (!user) {
|
|
router.push('/login?redirect=/notifications');
|
|
}
|
|
}, [user, router]);
|
|
|
|
// Fetch notifications
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
const fetchNotifications = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await notificationApi.getNotifications(
|
|
page,
|
|
pageSize,
|
|
filter === 'unread'
|
|
);
|
|
setNotifications(response.notifications);
|
|
setTotal(response.total);
|
|
setUnreadCount(response.unread_count);
|
|
} catch (error) {
|
|
console.error('Failed to fetch notifications:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchNotifications();
|
|
}, [user, page, filter]);
|
|
|
|
// Mark as read and navigate
|
|
const handleNotificationClick = async (notification: Notification) => {
|
|
if (!notification.is_read) {
|
|
try {
|
|
await notificationApi.markAsRead([notification.id]);
|
|
setNotifications(prev =>
|
|
prev.map(n => n.id === notification.id ? { ...n, is_read: true } : n)
|
|
);
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
} catch (error) {
|
|
console.error('Failed to mark as read:', error);
|
|
}
|
|
}
|
|
|
|
if (notification.link) {
|
|
router.push(notification.link);
|
|
}
|
|
};
|
|
|
|
// Mark all as read
|
|
const handleMarkAllRead = async () => {
|
|
try {
|
|
await notificationApi.markAllAsRead();
|
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
|
setUnreadCount(0);
|
|
} catch (error) {
|
|
console.error('Failed to mark all as read:', error);
|
|
}
|
|
};
|
|
|
|
// Delete notification
|
|
const handleDelete = async (e: React.MouseEvent, notificationId: number) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await notificationApi.deleteNotification(notificationId);
|
|
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
|
setTotal(prev => prev - 1);
|
|
} catch (error) {
|
|
console.error('Failed to delete notification:', error);
|
|
}
|
|
};
|
|
|
|
// Format time
|
|
const formatTime = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffMins < 1) return '방금 전';
|
|
if (diffMins < 60) return `${diffMins}분 전`;
|
|
if (diffHours < 24) return `${diffHours}시간 전`;
|
|
if (diffDays < 7) return `${diffDays}일 전`;
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
// Get notification icon
|
|
const getNotificationIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'vehicle_recommended': return '🚗';
|
|
case 'shipping_update': return '🚚';
|
|
case 'withdrawal_processed': return '💰';
|
|
case 'referral_reward': return '🎁';
|
|
case 'dealer_approved': return '✅';
|
|
case 'dealer_rejected': return '❌';
|
|
case 'share_purchased': return '🎉';
|
|
case 'system': return '📢';
|
|
default: return '🔔';
|
|
}
|
|
};
|
|
|
|
// Get notification type label
|
|
const getTypeLabel = (type: string) => {
|
|
switch (type) {
|
|
case 'vehicle_recommended': return '차량 추천';
|
|
case 'shipping_update': return '배송 업데이트';
|
|
case 'withdrawal_processed': return '출금 처리';
|
|
case 'referral_reward': return '레퍼럴 보상';
|
|
case 'dealer_approved': return '딜러 승인';
|
|
case 'dealer_rejected': return '딜러 거부';
|
|
case 'share_purchased': return '공유 판매';
|
|
case 'system': return '시스템 알림';
|
|
default: return '알림';
|
|
}
|
|
};
|
|
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-8">
|
|
<div className="container mx-auto px-4 max-w-3xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-800">
|
|
{t.notifications || '알림'}
|
|
</h1>
|
|
<p className="text-gray-600 mt-1">
|
|
{unreadCount > 0 ? (
|
|
<span className="text-primary-600 font-medium">
|
|
읽지 않은 알림 {unreadCount}개
|
|
</span>
|
|
) : (
|
|
'모든 알림을 확인했습니다'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{unreadCount > 0 && (
|
|
<button
|
|
onClick={handleMarkAllRead}
|
|
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
|
>
|
|
{t.markAllRead || '모두 읽음 처리'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter Tabs */}
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
onClick={() => { setFilter('all'); setPage(1); }}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
|
filter === 'all'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
전체
|
|
</button>
|
|
<button
|
|
onClick={() => { setFilter('unread'); setPage(1); }}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
|
filter === 'unread'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
읽지 않음 ({unreadCount})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Notification List */}
|
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-8 text-center">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
|
<p className="mt-4 text-gray-500">알림을 불러오는 중...</p>
|
|
</div>
|
|
) : notifications.length === 0 ? (
|
|
<div className="p-12 text-center">
|
|
<div className="text-6xl mb-4">🔔</div>
|
|
<p className="text-gray-500 text-lg">
|
|
{filter === 'unread' ? '읽지 않은 알림이 없습니다' : '알림이 없습니다'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
className={`p-4 hover:bg-gray-50 cursor-pointer transition ${
|
|
!notification.is_read ? 'bg-blue-50' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
{/* Icon */}
|
|
<div className="text-3xl flex-shrink-0">
|
|
{getNotificationIcon(notification.notification_type)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
|
{getTypeLabel(notification.notification_type)}
|
|
</span>
|
|
{!notification.is_read && (
|
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
|
)}
|
|
</div>
|
|
|
|
<h3 className={`text-base ${!notification.is_read ? 'font-semibold' : 'font-medium'} text-gray-800`}>
|
|
{notification.title}
|
|
</h3>
|
|
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{notification.message}
|
|
</p>
|
|
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{formatTime(notification.created_at)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex-shrink-0">
|
|
<button
|
|
onClick={(e) => handleDelete(e, notification.id)}
|
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition"
|
|
title="삭제"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center items-center gap-2 mt-6">
|
|
<button
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
이전
|
|
</button>
|
|
|
|
<span className="px-4 py-2 text-gray-600">
|
|
{page} / {totalPages}
|
|
</span>
|
|
|
|
<button
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Back Link */}
|
|
<div className="mt-8 text-center">
|
|
<Link href="/" className="text-primary-600 hover:text-primary-700">
|
|
← 홈으로 돌아가기
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|