From 0d49c4b8aec9ef34937164e1827f3241ceb0c19a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 06:15:41 +0000 Subject: [PATCH] Implement notification history feature Adds a notification history view accessible from the header, including a clear button and a notification count badge. --- package-lock.json | 21 +++ package.json | 2 + src/components/Header.tsx | 16 ++- .../notification/NotificationPopover.tsx | 123 ++++++++++++++++++ src/hooks/useNotifications.ts | 71 ++++++++++ src/pages/Index.tsx | 17 +++ 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/components/notification/NotificationPopover.tsx create mode 100644 src/hooks/useNotifications.ts diff --git a/package-lock.json b/package-lock.json index 74ae4fe..36f879b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.56.2", + "@types/uuid": "^10.0.0", "browserslist": "^4.24.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -63,6 +64,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, @@ -3344,6 +3346,12 @@ "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", "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": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz", @@ -7941,6 +7949,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "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": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", diff --git a/package.json b/package.json index 8a51587..a571fb1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.56.2", + "@types/uuid": "^10.0.0", "browserslist": "^4.24.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -66,6 +67,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ca869ee..48c8d69 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,11 +1,12 @@ 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 { Skeleton } from '@/components/ui/skeleton'; +import { useAuth } from '@/contexts/auth'; import { useIsMobile } from '@/hooks/use-mobile'; import { isIOSPlatform } from '@/utils/platform'; +import NotificationPopover from './notification/NotificationPopover'; +import useNotifications from '@/hooks/useNotifications'; const Header: React.FC = () => { const { @@ -16,6 +17,7 @@ const Header: React.FC = () => { const [imageError, setImageError] = useState(false); const isMobile = useIsMobile(); const [isIOS, setIsIOS] = useState(false); + const { notifications, clearAllNotifications, markAsRead } = useNotifications(); // 플랫폼 감지 useEffect(() => { @@ -64,9 +66,13 @@ const Header: React.FC = () => {

젤리의 적자탈출

- +
+ +
); diff --git a/src/components/notification/NotificationPopover.tsx b/src/components/notification/NotificationPopover.tsx new file mode 100644 index 0000000..116f2bc --- /dev/null +++ b/src/components/notification/NotificationPopover.tsx @@ -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 = ({ + 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 ( + + + + + +
+
+ +

알림

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ {notifications.length > 0 && ( + + )} +
+ + + +
+ {notifications.length === 0 ? ( +
+ 알림이 없습니다. +
+ ) : ( + notifications.map((notification) => ( +
+
+
+

{notification.title}

+

{notification.message}

+

{formatDate(notification.timestamp)}

+
+ +
+
+ )) + )} +
+
+
+ ); +}; + +export default NotificationPopover; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..140a4f5 --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -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([]); + + // 로컬 스토리지에서 알림 불러오기 + 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; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index cf42ca9..074d015 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/auth'; import { useWelcomeDialog } from '@/hooks/useWelcomeDialog'; import { useDataInitialization } from '@/hooks/useDataInitialization'; import { useIsMobile } from '@/hooks/use-mobile'; +import useNotifications from '@/hooks/useNotifications'; // 메인 컴포넌트 const Index = () => { @@ -28,6 +29,7 @@ const Index = () => { const { showWelcome, checkWelcomeDialogState, handleCloseWelcome } = useWelcomeDialog(); const { isInitialized } = useDataInitialization(resetBudgetData); const isMobile = useIsMobile(); + const { addNotification } = useNotifications(); // 초기화 후 환영 메시지 표시 상태 확인 useEffect(() => { @@ -37,6 +39,21 @@ const Index = () => { } }, [isInitialized, checkWelcomeDialogState]); + // 앱 시작시 예시 알림 추가 (실제 앱에서는 필요한 이벤트에 따라 알림 추가) + useEffect(() => { + if (isInitialized && user) { + // 사용자 로그인 시 알림 예시 + const timeoutId = setTimeout(() => { + addNotification( + '환영합니다!', + '젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요.' + ); + }, 2000); + + return () => clearTimeout(timeoutId); + } + }, [isInitialized, user, addNotification]); + // 페이지가 처음 로드될 때 데이터 로딩 확인 useEffect(() => { console.log('Index 페이지 마운트, 현재 데이터 상태:');