diff options
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>
+ )
+}
|
