summaryrefslogtreecommitdiff
path: root/components/notice/notice-modal-manager.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/notice/notice-modal-manager.tsx')
-rw-r--r--components/notice/notice-modal-manager.tsx307
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>
+ )
+}