Files
goldenChart/frontend/src/components/TradeSignalSnackbar.tsx
T
2026-05-27 16:51:57 +09:00

358 lines
11 KiB
TypeScript

/**
* 매매 시그널 알림 팝업 — 화면 크기 기반 페이지·좌우 슬라이드
*/
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 = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="15 6 9 12 15 18" />
</svg>
);
const IcSlideNext = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="9 6 15 12 9 18" />
</svg>
);
export const TradeSignalSnackbar: React.FC<Props> = ({
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<Record<string, { x: number; y: number }>>({});
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) => (
<ToastCard
key={item.id}
item={item}
stackIndex={index}
compact={compactCard}
position={positionsRef.current[item.id]}
onDragStart={handleDragStart}
onClose={() => {
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 (
<div
key={layoutKey}
className={`${stack}${showSlideNav ? ' tsn-stack--has-nav' : ''}`}
style={containerStyle}
aria-live="polite"
aria-label="매매 시그널 알림"
>
<div className="tsn-stack-toolbar">
<div className="tsn-toolbar-actions">
<button
type="button"
className="tsn-dismiss-page"
onClick={dismissCurrentPage}
disabled={pageItems.length === 0}
title={`현재 화면에 보이는 알림 ${pageItems.length}건 닫기`}
>
알림창 닫기 ({pageItems.length})
</button>
<button
type="button"
className="tsn-dismiss-all"
onClick={dismissAll}
disabled={toastNotifications.length === 0}
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기 (모든 페이지)`}
>
전체 닫기 ({toastNotifications.length})
</button>
</div>
{showSlideNav && (
<span className="tsn-pager-label">
{safePage + 1} / {totalPages}
<span className="tsn-pager-sub"> · 화면당 {pageSize}</span>
</span>
)}
</div>
<div className={`tsn-slide-row${showSlideNav ? ' tsn-slide-row--nav' : ''}`}>
{showSlideNav && (
<button
type="button"
className="tsn-slide-nav tsn-slide-nav--prev"
onClick={goPrev}
disabled={safePage <= 0}
aria-label="이전 알림 목록"
title="이전 알림"
>
<IcSlidePrev />
</button>
)}
{showSlideNav ? (
<div className="tsn-slide-body">
<div {...cardsProps}>{cardList}</div>
</div>
) : (
<div {...cardsProps}>{cardList}</div>
)}
{showSlideNav && (
<button
type="button"
className="tsn-slide-nav tsn-slide-nav--next"
onClick={goNext}
disabled={safePage >= totalPages - 1}
aria-label="다음 알림 목록"
title="다음 알림"
>
<IcSlideNext />
</button>
)}
</div>
</div>
);
};
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 (
<div
data-tsn-card
className={`tsn-card ${isBuy ? 'tsn-card--buy' : 'tsn-card--sell'}${stackIndex === 0 ? ' tsn-card--latest' : ''}${compact ? ' tsn-card--compact' : ''}${isDragged ? ' tsn-card--dragging' : ''}`}
style={
isDragged
? {
position: 'fixed',
left: position!.x,
top: position!.y,
right: 'auto',
width: compact ? 320 : 380,
zIndex: 10050 + stackIndex,
}
: undefined
}
>
<div
className="gc-popup-header tsn-card-header"
onPointerDown={e => onDragStart(e, item.id)}
style={{ touchAction: 'none' }}
>
<span className={`tsn-badge ${isBuy ? 'tsn-badge--buy' : 'tsn-badge--sell'}`}>
{item.signalType}
</span>
<span className="gc-popup-title tsn-card-title" title={headline}>
{headline}
</span>
<button type="button" className="gc-popup-close tsn-icon-btn" onClick={onClose} title="닫기" aria-label="닫기">
</button>
</div>
<TradeSignalDetailBody item={item} variant="compact" />
<div className={`tsn-card-actions${compact ? ' tsn-card-actions--compact' : ''}`}>
<button type="button" className="tsn-action-btn tsn-action-btn--primary" onClick={onChart}>
차트
</button>
<button type="button" className="tsn-action-btn" onClick={onDetail}>
상세
</button>
<button type="button" className="tsn-action-btn" onClick={onList}>
목록
</button>
</div>
</div>
);
};
export default TradeSignalSnackbar;