357 lines
11 KiB
TypeScript
357 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,
|
|
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;
|
|
dismissToasts(toastNotifications.map(n => n.id));
|
|
setPageIndex(0);
|
|
positionsRef.current = {};
|
|
}, [dismissToasts, toastNotifications]);
|
|
|
|
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;
|