/** * 매매 시그널 알림 팝업 — 화면 크기 기반 페이지·좌우 슬라이드 */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import TradeSignalDetailBody from './TradeSignalDetailBody'; import { getSignalHeadline } from '../utils/tradeSignalDisplay'; import { type TradeAlertPopupLayout, type TradeAlertPopupPosition, normalizeTradeAlertGridCols, tradeAlertPopupClassNames, } from '../utils/tradeAlertPopupLayout'; import { useToastPageCapacity } from '../hooks/useToastPageCapacity'; interface Props { onGoToChart: (market: string) => void; onOpenList: () => void; popupPosition?: TradeAlertPopupPosition; popupLayout?: TradeAlertPopupLayout; gridCols?: number; } const IcSlidePrev = () => ( ); const IcSlideNext = () => ( ); export const TradeSignalSnackbar: React.FC = ({ onGoToChart, onOpenList, popupPosition = 'right', popupLayout = 'stack', gridCols = 2, }) => { const { toastNotifications, dismissToast, dismissToasts, dismissAllToasts, openDetail, } = useTradeNotification(); const cols = normalizeTradeAlertGridCols(gridCols); const { pageSize, effectiveCols } = useToastPageCapacity(popupLayout, popupPosition, cols); const { stack, cards } = tradeAlertPopupClassNames(popupPosition, popupLayout); const layoutKey = `${popupPosition}-${popupLayout}-${cols}`; const [pageIndex, setPageIndex] = useState(0); const swipeRef = useRef<{ startX: number; startY: number } | null>(null); const positionsRef = useRef>({}); const dragRef = useRef<{ key: string; startX: number; startY: number; startLeft: number; startTop: number; } | null>(null); const [, forceDragRender] = React.useReducer((x: number) => x + 1, 0); const unbindDragRef = useRef<(() => void) | null>(null); const totalPages = Math.max(1, Math.ceil(toastNotifications.length / pageSize)); const safePage = Math.min(pageIndex, totalPages - 1); const pageItems = useMemo(() => { const start = safePage * pageSize; return toastNotifications.slice(start, start + pageSize); }, [toastNotifications, safePage, pageSize]); useEffect(() => { positionsRef.current = {}; setPageIndex(0); }, [layoutKey]); useEffect(() => { setPageIndex(p => Math.min(p, Math.max(0, totalPages - 1))); }, [totalPages, pageSize]); const goPrev = useCallback(() => { setPageIndex(p => Math.max(0, p - 1)); }, []); const goNext = useCallback(() => { setPageIndex(p => Math.min(totalPages - 1, p + 1)); }, [totalPages]); const dismissAll = useCallback(() => { if (toastNotifications.length === 0) return; dismissAllToasts(); setPageIndex(0); positionsRef.current = {}; }, [dismissAllToasts, toastNotifications.length]); const dismissCurrentPage = useCallback(() => { const ids = pageItems.map(i => i.id); if (ids.length === 0) return; dismissToasts(ids); positionsRef.current = Object.fromEntries( Object.entries(positionsRef.current).filter(([id]) => !ids.includes(id)), ); const remaining = toastNotifications.length - ids.length; const newTotalPages = Math.max(1, Math.ceil(remaining / pageSize)); setPageIndex(p => Math.min(p, newTotalPages - 1)); }, [dismissToasts, pageItems, toastNotifications.length, pageSize]); const handleDragStart = useCallback((e: React.PointerEvent, key: string) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest('button')) return; e.preventDefault(); const el = (e.currentTarget as HTMLElement).closest('[data-tsn-card]') as HTMLElement; if (!el) return; try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* ignore */ } const rect = el.getBoundingClientRect(); const pos = positionsRef.current[key]; const { x, y } = clientXYFromReact(e); dragRef.current = { key, startX: x, startY: y, startLeft: pos?.x ?? rect.left, startTop: pos?.y ?? rect.top, }; unbindDragRef.current?.(); unbindDragRef.current = bindWindowDrag( ({ x: cx, y: cy }) => { const s = dragRef.current; if (!s) return; positionsRef.current = { ...positionsRef.current, [s.key]: { x: s.startLeft + (cx - s.startX), y: s.startTop + (cy - s.startY), }, }; forceDragRender(); }, () => { dragRef.current = null; unbindDragRef.current = null; }, ); }, []); const handleSwipeStart = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest('button')) return; swipeRef.current = { startX: e.clientX, startY: e.clientY }; }, []); const handleSwipeEnd = useCallback((e: React.PointerEvent) => { const s = swipeRef.current; swipeRef.current = null; if (!s || totalPages <= 1) return; const dx = e.clientX - s.startX; const dy = e.clientY - s.startY; if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return; if (dx < 0) goNext(); else goPrev(); }, [goNext, goPrev, totalPages]); if (toastNotifications.length === 0) return null; const compactCard = popupLayout === 'grid' || popupLayout === 'strip'; const gridColsEffective = popupLayout === 'grid' ? effectiveCols : cols; const containerStyle = popupLayout === 'grid' ? ({ ['--tsn-grid-cols' as string]: gridColsEffective } as React.CSSProperties) : undefined; const showSlideNav = totalPages > 1; const cardList = ( <> {pageItems.map((item, index) => ( { delete positionsRef.current[item.id]; dismissToast(item.id); }} onDetail={() => openDetail(item)} onChart={() => { delete positionsRef.current[item.id]; dismissToast(item.id); onGoToChart(item.market); }} onList={onOpenList} /> ))} > ); const cardsProps = { className: `${cards} tsn-cards--paged`, style: containerStyle, onPointerDown: handleSwipeStart, onPointerUp: handleSwipeEnd, onPointerCancel: () => { swipeRef.current = null; }, }; return ( 알림창 닫기 ({pageItems.length}) 전체 닫기 ({toastNotifications.length}) {showSlideNav && ( {safePage + 1} / {totalPages} · 화면당 {pageSize}건 )} {showSlideNav && ( )} {showSlideNav ? ( {cardList} ) : ( {cardList} )} {showSlideNav && ( = totalPages - 1} aria-label="다음 알림 목록" title="다음 알림" > )} ); }; const ToastCard: React.FC<{ item: TradeNotificationItem; stackIndex: number; compact?: boolean; position?: { x: number; y: number }; onDragStart: (e: React.PointerEvent, key: string) => void; onClose: () => void; onDetail: () => void; onChart: () => void; onList: () => void; }> = ({ item, stackIndex, compact, position, onDragStart, onClose, onDetail, onChart, onList }) => { const isBuy = item.signalType === 'BUY'; const isDragged = Boolean(position); const headline = getSignalHeadline(item); return ( onDragStart(e, item.id)} style={{ touchAction: 'none' }} > {item.signalType} {headline} ✕ 차트 상세 목록 ); }; export default TradeSignalSnackbar;