From 9f761849c2e98f650d089d00aed9df090497ada9 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 27 Oct 2025 03:12:26 +0000 Subject: (최겸) 공지사항 팝업기능 및 다시보지않기 기능 구현(로컬 스토리지 활용) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/notice/notice-modal-manager.tsx | 307 +++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 components/notice/notice-modal-manager.tsx (limited to 'components/notice/notice-modal-manager.tsx') diff --git a/components/notice/notice-modal-manager.tsx b/components/notice/notice-modal-manager.tsx new file mode 100644 index 00000000..fd075efe --- /dev/null +++ b/components/notice/notice-modal-manager.tsx @@ -0,0 +1,307 @@ +"use client" + +import React, { useState, useEffect, useTransition, useCallback } from "react" +// usePathname 제거 +import { useTranslation } from "@/i18n/client" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { AlertCircle, Calendar, Clock, User } from "lucide-react" +import { toast } from "sonner" +import { getPageNoticesForModal } from "@/lib/notice/service" +import { + setNoticeDontShow, + isNoticeDontShowValid, +} from "@/lib/notice/storage-utils" +import type { Notice } from "@/db/schema/notice" +import { formatDate } from "@/lib/utils" + +type NoticeWithAuthor = Notice & { + authorName: string | null + authorEmail: string | null +} + +interface NoticeModalState { + notices: NoticeWithAuthor[] + currentIndex: number + isOpen: boolean + dontShowToday: boolean + dontShowSettings: Record +} + +interface NoticeModalManagerProps { + pagePath: string // 💡 필수 prop으로 변경 + autoOpen?: boolean +} + +export function NoticeModalManager({ pagePath, autoOpen = true }: NoticeModalManagerProps) { + const { t } = useTranslation('ko', 'menu') + + // 💡 usePathname() 제거 + + const [state, setState] = useState({ + notices: [], + currentIndex: 0, + isOpen: false, + dontShowToday: false, + dontShowSettings: {} + }) + + const [isPending, startTransition] = useTransition() + const [isLoading, setIsLoading] = useState(true) + + // 💡 클라이언트 마운트 여부 확인 (하이드레이션 불일치 방지) + const [isMounted, setIsMounted] = useState(false) + + // 현재 표시할 공지사항 + const currentNotice = state.notices[state.currentIndex] + + // 💡 [수정] useCallback 대신 useEffect 내에서 직접 로직 처리 (훅 순서 안정화) + useEffect(() => { + // 1. 클라이언트 마운트 확인 + setIsMounted(true) + + if (!pagePath || !autoOpen) { + setIsLoading(false) // 마운트 됐는데 실행 조건이 안 되면 로딩 끝내기 + return + } + + // 2. 데이터 페치 로직을 startTransition 내에서 실행 (단일 호출 보장) + setIsLoading(true) + + startTransition(async () => { + try { + const allNotices = await getPageNoticesForModal(pagePath) + + if (allNotices.length === 0) { + setState(prev => ({ ...prev, notices: [], isOpen: false })) + return + } + + // 로컬 스토리지 설정 확인 후 필터링 + const filteredNotices = allNotices.filter(notice => { + const dontShowDay = isNoticeDontShowValid({ noticeId: notice.id, duration: 'day' }) + const dontShowNever = isNoticeDontShowValid({ noticeId: notice.id, duration: 'never' }) + return !dontShowDay && !dontShowNever + }) + + if (filteredNotices.length === 0) { + setState(prev => ({ ...prev, notices: [], isOpen: false })) + } else { + const dontShowSettings: Record = {} + filteredNotices.forEach(notice => { + dontShowSettings[notice.id] = false + }) + + setState(prev => ({ + ...prev, + notices: filteredNotices, + currentIndex: 0, + isOpen: autoOpen, + dontShowSettings + })) + } + } catch (error) { + console.error("공지사항 조회 중 오류 발생:", error) + toast.error("공지사항을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + }) + }, [pagePath, autoOpen]) // startTransition은 React에서 제공하는 함수이므로 의존성에서 제외 + + // 💡 [수정] '다시 보지 않기' 체크박스 변경 핸들러 + const handleDontShowChange = useCallback((noticeId: number, checked: boolean) => { + setState(prev => ({ + ...prev, + dontShowSettings: { + ...prev.dontShowSettings, + [noticeId]: checked + } + })) + }, []) + + // 💡 [수정] 다음 공지사항으로 이동 + const handleNext = useCallback(() => { + if (state.currentIndex < state.notices.length - 1) { + setState(prev => ({ + ...prev, + currentIndex: prev.currentIndex + 1 + })) + } + }, [state.currentIndex, state.notices.length]) + + // 💡 [수정] 이전 공지사항으로 이동 + const handlePrevious = useCallback(() => { + if (state.currentIndex > 0) { + setState(prev => ({ + ...prev, + currentIndex: prev.currentIndex - 1 + })) + } + }, [state.currentIndex]) + + // 💡 [수정] 공지사항 닫기 (설정 저장 포함) + const handleClose = useCallback(() => { + // 체크된 '다시 보지 않기' 설정 저장 + Object.entries(state.dontShowSettings).forEach(([noticeId, checked]) => { + if (checked) { + setNoticeDontShow({ + noticeId: parseInt(noticeId), + duration: 'day' + }) + } + }) + + setState(prev => ({ + ...prev, + isOpen: false, + notices: [], + currentIndex: 0, + dontShowSettings: {} + })) + + if (Object.values(state.dontShowSettings).some(Boolean)) { + toast.success("설정이 저장되었습니다.") + } + }, [state.dontShowSettings]) + + // 💡 3. 하이드레이션 불일치 방지: + // 서버 렌더링 시나 클라이언트 마운트 직후에는 null을 반환하여 서버의 DOM과 일치시킵니다. + if (!isMounted) { + return null + } + + // 4. 이후 원래의 조건부 렌더링 로직 유지 + if (isLoading || state.notices.length === 0 || !currentNotice) { + return null // 데이터 로드 중이거나 공지사항이 없거나 currentNotice가 없으면 null + } + + return ( + + + +
+ + + 공지사항 + + + {state.currentIndex + 1} / {state.notices.length} + +
+ + 중요한 공지사항을 확인해주세요. + +
+ +
+ {/* 공지사항 정보 헤더 */} +
+
+
+

+ {currentNotice?.title} +

+
+
+ + {currentNotice?.authorName || "알 수 없음"} +
+
+ + {formatDate(currentNotice?.createdAt || new Date(), "KR")} +
+ {currentNotice?.pagePath && ( + + {currentNotice.pagePath} + + )} +
+
+
+
+ + + + {/* 공지사항 내용 */} + +
+ + + {/* 유효기간 정보 */} + {(currentNotice?.startAt || currentNotice?.endAt) && ( + <> + +
+ + 유효기간: + {currentNotice.startAt && ( + 시작 {formatDate(currentNotice.startAt, "KR")} + )} + {currentNotice.startAt && currentNotice.endAt && ~ } + {currentNotice.endAt && ( + 종료 {formatDate(currentNotice.endAt, "KR")} + )} +
+ + )} + + {/* '다시 보지 않기' 설정 */} +
+ + handleDontShowChange(currentNotice?.id || 0, checked as boolean) + } + /> + +
+
+ + {/* 하단 버튼들 */} +
+
+ {state.currentIndex > 0 && ( + + )} +
+ +
+ {state.currentIndex < state.notices.length - 1 ? ( + + ) : ( + + )} +
+
+ +
+ ) +} -- cgit v1.2.3