diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-27 03:12:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-27 03:12:26 +0000 |
| commit | 9f761849c2e98f650d089d00aed9df090497ada9 (patch) | |
| tree | cb06b8dad5b34d1cd17997cdc82b2de5b981d965 /components/notice/notice-modal-manager.tsx | |
| parent | 94f55e3300063511c2799096128afa1b815f4f56 (diff) | |
(최겸) 공지사항 팝업기능 및 다시보지않기 기능 구현(로컬 스토리지 활용)
Diffstat (limited to 'components/notice/notice-modal-manager.tsx')
| -rw-r--r-- | components/notice/notice-modal-manager.tsx | 307 |
1 files changed, 307 insertions, 0 deletions
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<number, boolean>
+}
+
+interface NoticeModalManagerProps {
+ pagePath: string // 💡 필수 prop으로 변경
+ autoOpen?: boolean
+}
+
+export function NoticeModalManager({ pagePath, autoOpen = true }: NoticeModalManagerProps) {
+ const { t } = useTranslation('ko', 'menu')
+
+ // 💡 usePathname() 제거
+
+ const [state, setState] = useState<NoticeModalState>({
+ 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<number, boolean> = {}
+ 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 (
+ <Dialog open={state.isOpen} onOpenChange={handleClose}>
+ <DialogContent className="max-w-2xl max-h-[80vh]">
+ <DialogHeader>
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-5 w-5 text-blue-500" />
+ <DialogTitle className="text-xl">
+ 공지사항
+ </DialogTitle>
+ <Badge variant="outline">
+ {state.currentIndex + 1} / {state.notices.length}
+ </Badge>
+ </div>
+ <DialogDescription>
+ 중요한 공지사항을 확인해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 공지사항 정보 헤더 */}
+ <div className="bg-muted/50 rounded-lg p-4">
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <h3 className="font-semibold text-lg mb-2">
+ {currentNotice?.title}
+ </h3>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <User className="h-4 w-4" />
+ {currentNotice?.authorName || "알 수 없음"}
+ </div>
+ <div className="flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {formatDate(currentNotice?.createdAt || new Date(), "KR")}
+ </div>
+ {currentNotice?.pagePath && (
+ <Badge variant="secondary" className="text-xs">
+ {currentNotice.pagePath}
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 공지사항 내용 */}
+ <ScrollArea className="h-[300px] w-full">
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: currentNotice?.content || ""
+ }}
+ />
+ </ScrollArea>
+
+ {/* 유효기간 정보 */}
+ {(currentNotice?.startAt || currentNotice?.endAt) && (
+ <>
+ <Separator />
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Clock className="h-4 w-4" />
+ <span>유효기간:</span>
+ {currentNotice.startAt && (
+ <span>시작 {formatDate(currentNotice.startAt, "KR")}</span>
+ )}
+ {currentNotice.startAt && currentNotice.endAt && <span> ~ </span>}
+ {currentNotice.endAt && (
+ <span>종료 {formatDate(currentNotice.endAt, "KR")}</span>
+ )}
+ </div>
+ </>
+ )}
+
+ {/* '다시 보지 않기' 설정 */}
+ <div className="flex items-center space-x-2 p-4 bg-muted/30 rounded-lg">
+ <Checkbox
+ id="dontShowToday"
+ checked={state.dontShowSettings[currentNotice?.id || 0] || false}
+ onCheckedChange={(checked) =>
+ handleDontShowChange(currentNotice?.id || 0, checked as boolean)
+ }
+ />
+ <label
+ htmlFor="dontShowToday"
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 오늘은 더 이상 이 공지사항을 표시하지 않음
+ </label>
+ </div>
+ </div>
+
+ {/* 하단 버튼들 */}
+ <div className="flex items-center justify-between pt-4">
+ <div className="flex items-center gap-2">
+ {state.currentIndex > 0 && (
+ <Button variant="outline" onClick={handlePrevious}>
+ 이전
+ </Button>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ {state.currentIndex < state.notices.length - 1 ? (
+ <Button onClick={handleNext}>
+ 다음 ({state.notices.length - state.currentIndex - 1}개 남음)
+ </Button>
+ ) : (
+ <Button onClick={handleClose}>
+ 확인
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
|
