1- import { useState , useEffect , useCallback , useRef } from 'react' ;
1+ import { useState , useEffect , useCallback , useRef , useLayoutEffect } from 'react' ;
2+ import { createPortal } from 'react-dom' ;
23import {
34 Bell , Check , CheckCheck , Trash2 , Loader2 , X ,
45 CreditCard , Users , Shield , Phone , Info , AlertTriangle , CheckCircle2 ,
@@ -30,10 +31,10 @@ const TYPE_ICONS: Record<string, React.ReactNode> = {
3031} ;
3132
3233const TYPE_COLORS : Record < string , string > = {
33- info : 'text-blue-400 bg-blue-500/10 ' ,
34- warning : 'text-orange-400 bg-orange-500/10 ' ,
35- success : 'text-emerald-400 bg-emerald-500 /10' ,
36- error : 'text-red-400 bg-red-500/10 ' ,
34+ info : 'text-[#FFB286] bg-[#FF8A5B]/12 ' ,
35+ warning : 'text-[#FF9E6C] bg-[#FF8A5B]/14 ' ,
36+ success : 'text-[#FFD1B8] bg-[#FF8A5B] /10' ,
37+ error : 'text-red-400 bg-red-500/12 ' ,
3738} ;
3839
3940const CATEGORY_ICONS : Record < string , React . ReactNode > = {
@@ -60,7 +61,9 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
6061 const [ loading , setLoading ] = useState ( false ) ;
6162 const [ notifications , setNotifications ] = useState < NotificationEntry [ ] > ( [ ] ) ;
6263 const [ unreadCount , setUnreadCount ] = useState ( 0 ) ;
64+ const buttonRef = useRef < HTMLButtonElement > ( null ) ;
6365 const dropdownRef = useRef < HTMLDivElement > ( null ) ;
66+ const [ dropdownStyle , setDropdownStyle ] = useState < React . CSSProperties > ( { } ) ;
6467
6568 const apiCall = useCallback ( async ( path : string , options ?: RequestInit ) => {
6669 try {
@@ -113,14 +116,47 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
113116 // Close on outside click
114117 useEffect ( ( ) => {
115118 const handler = ( e : MouseEvent ) => {
116- if ( dropdownRef . current && ! dropdownRef . current . contains ( e . target as Node ) ) {
117- setOpen ( false ) ;
118- }
119+ const target = e . target as Node ;
120+ if ( dropdownRef . current ?. contains ( target ) || buttonRef . current ?. contains ( target ) ) return ;
121+ setOpen ( false ) ;
119122 } ;
120123 if ( open ) document . addEventListener ( 'mousedown' , handler ) ;
121124 return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
122125 } , [ open ] ) ;
123126
127+ const updatePosition = useCallback ( ( ) => {
128+ const btn = buttonRef . current ;
129+ if ( ! btn ) return ;
130+ const rect = btn . getBoundingClientRect ( ) ;
131+ const isMobileViewport = window . innerWidth < 640 ;
132+ const width = Math . min ( 380 , Math . floor ( window . innerWidth * 0.92 ) ) ;
133+ const left = isMobileViewport
134+ ? ( window . innerWidth - width ) / 2
135+ : Math . min ( Math . max ( rect . right - width , 8 ) , window . innerWidth - width - 8 ) ;
136+ const top = Math . min ( rect . bottom + 12 , window . innerHeight - 16 ) ;
137+ const maxHeight = Math . max ( 240 , window . innerHeight - top - 16 ) ;
138+ setDropdownStyle ( {
139+ position : 'fixed' ,
140+ top,
141+ left,
142+ width,
143+ maxHeight,
144+ } ) ;
145+ } , [ ] ) ;
146+
147+ useLayoutEffect ( ( ) => {
148+ if ( ! open ) return ;
149+ updatePosition ( ) ;
150+ const onResize = ( ) => updatePosition ( ) ;
151+ const onScroll = ( ) => updatePosition ( ) ;
152+ window . addEventListener ( 'resize' , onResize ) ;
153+ window . addEventListener ( 'scroll' , onScroll , true ) ;
154+ return ( ) => {
155+ window . removeEventListener ( 'resize' , onResize ) ;
156+ window . removeEventListener ( 'scroll' , onScroll , true ) ;
157+ } ;
158+ } , [ open , updatePosition ] ) ;
159+
124160 const markAllRead = async ( ) => {
125161 try {
126162 await apiCall ( 'mark-read' , { method : 'POST' , body : JSON . stringify ( { ids : [ ] } ) } ) ;
@@ -145,9 +181,10 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
145181 } ;
146182
147183 return (
148- < div className = { cn ( "relative" , className ) } ref = { dropdownRef } >
184+ < div className = { cn ( "relative" , className ) } >
149185 { /* Bell Button */ }
150186 < button
187+ ref = { buttonRef }
151188 onClick = { ( ) => setOpen ( ! open ) }
152189 className = { cn (
153190 "relative inline-flex h-8 w-8 items-center justify-center rounded-full border transition-colors" ,
@@ -170,12 +207,18 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
170207 </ button >
171208
172209 { /* Dropdown */ }
173- { open && (
174- < div className = { cn (
175- "absolute top-full mt-3 w-[min(92vw,380px)] max-h-[480px] rounded-2xl border shadow-2xl z-[100] flex flex-col overflow-hidden backdrop-blur-xl" ,
176- "left-1/2 -translate-x-1/2 sm:left-auto sm:right-0 sm:translate-x-0" ,
177- isDark ? "bg-[#0F0F12]/95 border-white/10 shadow-black/60" : "bg-white/95 border-gray-200 shadow-lg shadow-gray-200/50"
178- ) } >
210+ { open && createPortal (
211+ < div
212+ ref = { dropdownRef }
213+ style = { dropdownStyle }
214+ className = { cn (
215+ "rounded-2xl border shadow-2xl z-[500] flex flex-col overflow-hidden backdrop-blur-2xl" ,
216+ "relative [&>*]:relative [&>*]:z-10" ,
217+ "before:content-[''] before:absolute before:inset-0 before:pointer-events-none before:bg-[radial-gradient(circle_at_20%_0%,rgba(255,138,91,0.16),transparent_60%)]" ,
218+ "after:content-[''] after:absolute after:inset-0 after:pointer-events-none after:bg-[url('/noise.webp')] after:bg-[length:30%] after:opacity-[0.08]" ,
219+ isDark ? "bg-[#0F0F12]/98 border-white/10 shadow-black/60" : "bg-white/98 border-gray-200 shadow-lg shadow-gray-200/50"
220+ ) }
221+ >
179222 { /* Header */ }
180223 < div className = { cn ( "px-4 py-3 border-b flex items-center justify-between shrink-0" , isDark ? "border-white/10" : "border-gray-100" ) } >
181224 < h3 className = { cn ( "text-sm font-semibold" , isDark ? "text-white" : "text-gray-900" ) } > Notifications</ h3 >
@@ -246,7 +289,8 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
246289 </ div >
247290 ) }
248291 </ div >
249- </ div >
292+ </ div > ,
293+ document . body
250294 ) }
251295 </ div >
252296 ) ;
0 commit comments