Initial commit: AutonetSellCar platform with deployment system
- 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>
This commit is contained in:
317
frontend/src/app/notifications/page.tsx
Normal file
317
frontend/src/app/notifications/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user