Files
AutonetSellCar/frontend/src/app/notifications/page.tsx
AutonetSellCar Deploy 1f0dcb1ddb 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>
2025-12-30 13:24:39 +09:00

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>
);
}