Implement notification history feature
Adds a notification history view accessible from the header, including a clear button and a notification count badge.
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -44,6 +44,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.4",
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@supabase/supabase-js": "^2.49.1",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"browserslist": "^4.24.4",
|
"browserslist": "^4.24.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -3344,6 +3346,12 @@
|
|||||||
"integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==",
|
"integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
|
||||||
@@ -7941,6 +7949,19 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.4",
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@supabase/supabase-js": "^2.49.1",
|
"@supabase/supabase-js": "^2.49.1",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"browserslist": "^4.24.4",
|
"browserslist": "^4.24.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^0.9.3",
|
"vaul": "^0.9.3",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Bell } from 'lucide-react';
|
|
||||||
import { useAuth } from '@/contexts/auth';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useAuth } from '@/contexts/auth';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { isIOSPlatform } from '@/utils/platform';
|
import { isIOSPlatform } from '@/utils/platform';
|
||||||
|
import NotificationPopover from './notification/NotificationPopover';
|
||||||
|
import useNotifications from '@/hooks/useNotifications';
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -16,6 +17,7 @@ const Header: React.FC = () => {
|
|||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
|
const { notifications, clearAllNotifications, markAsRead } = useNotifications();
|
||||||
|
|
||||||
// 플랫폼 감지
|
// 플랫폼 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,9 +66,13 @@ const Header: React.FC = () => {
|
|||||||
<p className="text-gray-500 text-left">젤리의 적자탈출</p>
|
<p className="text-gray-500 text-left">젤리의 적자탈출</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="neuro-flat p-2.5 rounded-full">
|
<div className="neuro-flat p-2.5 rounded-full">
|
||||||
<Bell size={20} className="text-gray-600" />
|
<NotificationPopover
|
||||||
</button>
|
notifications={notifications}
|
||||||
|
onClearAll={clearAllNotifications}
|
||||||
|
onReadNotification={markAsRead}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
123
src/components/notification/NotificationPopover.tsx
Normal file
123
src/components/notification/NotificationPopover.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { BellRing, X, Check } from 'lucide-react';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// 알림 타입 정의
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPopoverProps {
|
||||||
|
notifications: Notification[];
|
||||||
|
onClearAll: () => void;
|
||||||
|
onReadNotification: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationPopover: React.FC<NotificationPopoverProps> = ({
|
||||||
|
notifications,
|
||||||
|
onClearAll,
|
||||||
|
onReadNotification
|
||||||
|
}) => {
|
||||||
|
const unreadCount = notifications.filter(notification => !notification.read).length;
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
onClearAll();
|
||||||
|
toast.success('모든 알림이 삭제되었습니다.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<BellRing size={20} className="text-gray-600" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
className="absolute -top-1 -right-1 px-1.5 py-0.5 min-w-5 h-5 flex items-center justify-center text-xs bg-neuro-income text-white border-2 border-neuro-background"
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0 neuro-flat" align="end">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<BellRing size={16} className="mr-2 text-neuro-income" />
|
||||||
|
<h3 className="font-medium">알림</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
className="ml-2 px-1.5 py-0.5 bg-neuro-income text-white"
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="text-xs hover:bg-red-100 hover:text-red-600"
|
||||||
|
>
|
||||||
|
모두 삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-gray-500">
|
||||||
|
알림이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<div key={notification.id} className={`p-4 border-b last:border-b-0 ${!notification.read ? 'bg-[#F2FCE2]' : ''}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-medium">{notification.title}</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{notification.message}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{formatDate(notification.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full hover:bg-gray-200"
|
||||||
|
onClick={() => onReadNotification(notification.id)}
|
||||||
|
>
|
||||||
|
{notification.read ? (
|
||||||
|
<Check size={14} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<X size={14} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationPopover;
|
||||||
71
src/hooks/useNotifications.ts
Normal file
71
src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Notification } from '@/components/notification/NotificationPopover';
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
// 로컬 스토리지에서 알림 불러오기
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const savedNotifications = localStorage.getItem('notifications');
|
||||||
|
if (savedNotifications) {
|
||||||
|
const parsedNotifications = JSON.parse(savedNotifications);
|
||||||
|
// 시간 문자열을 Date 객체로 변환
|
||||||
|
const formattedNotifications = parsedNotifications.map((notification: any) => ({
|
||||||
|
...notification,
|
||||||
|
timestamp: new Date(notification.timestamp)
|
||||||
|
}));
|
||||||
|
setNotifications(formattedNotifications);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('알림 데이터 로드 중 오류 발생:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 알림 추가
|
||||||
|
const addNotification = (title: string, message: string) => {
|
||||||
|
const newNotification: Notification = {
|
||||||
|
id: uuidv4(),
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
read: false
|
||||||
|
};
|
||||||
|
|
||||||
|
setNotifications(prevNotifications => {
|
||||||
|
const updatedNotifications = [newNotification, ...prevNotifications];
|
||||||
|
// 로컬 스토리지 업데이트
|
||||||
|
localStorage.setItem('notifications', JSON.stringify(updatedNotifications));
|
||||||
|
return updatedNotifications;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 알림 읽음 표시
|
||||||
|
const markAsRead = (id: string) => {
|
||||||
|
setNotifications(prevNotifications => {
|
||||||
|
const updatedNotifications = prevNotifications.map(notification =>
|
||||||
|
notification.id === id ? { ...notification, read: true } : notification
|
||||||
|
);
|
||||||
|
// 로컬 스토리지 업데이트
|
||||||
|
localStorage.setItem('notifications', JSON.stringify(updatedNotifications));
|
||||||
|
return updatedNotifications;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 알림 삭제
|
||||||
|
const clearAllNotifications = () => {
|
||||||
|
setNotifications([]);
|
||||||
|
localStorage.removeItem('notifications');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
addNotification,
|
||||||
|
markAsRead,
|
||||||
|
clearAllNotifications
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useNotifications;
|
||||||
@@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/auth';
|
|||||||
import { useWelcomeDialog } from '@/hooks/useWelcomeDialog';
|
import { useWelcomeDialog } from '@/hooks/useWelcomeDialog';
|
||||||
import { useDataInitialization } from '@/hooks/useDataInitialization';
|
import { useDataInitialization } from '@/hooks/useDataInitialization';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import useNotifications from '@/hooks/useNotifications';
|
||||||
|
|
||||||
// 메인 컴포넌트
|
// 메인 컴포넌트
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
@@ -28,6 +29,7 @@ const Index = () => {
|
|||||||
const { showWelcome, checkWelcomeDialogState, handleCloseWelcome } = useWelcomeDialog();
|
const { showWelcome, checkWelcomeDialogState, handleCloseWelcome } = useWelcomeDialog();
|
||||||
const { isInitialized } = useDataInitialization(resetBudgetData);
|
const { isInitialized } = useDataInitialization(resetBudgetData);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
// 초기화 후 환영 메시지 표시 상태 확인
|
// 초기화 후 환영 메시지 표시 상태 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +39,21 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
}, [isInitialized, checkWelcomeDialogState]);
|
}, [isInitialized, checkWelcomeDialogState]);
|
||||||
|
|
||||||
|
// 앱 시작시 예시 알림 추가 (실제 앱에서는 필요한 이벤트에 따라 알림 추가)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized && user) {
|
||||||
|
// 사용자 로그인 시 알림 예시
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
addNotification(
|
||||||
|
'환영합니다!',
|
||||||
|
'젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요.'
|
||||||
|
);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isInitialized, user, addNotification]);
|
||||||
|
|
||||||
// 페이지가 처음 로드될 때 데이터 로딩 확인
|
// 페이지가 처음 로드될 때 데이터 로딩 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Index 페이지 마운트, 현재 데이터 상태:');
|
console.log('Index 페이지 마운트, 현재 데이터 상태:');
|
||||||
|
|||||||
Reference in New Issue
Block a user