goldenChat base source add
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 매매 시그널 알림 팝업 — 화면 크기 기반 페이지·좌우 슬라이드
|
||||
*/
|
||||
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 dismissCurrentPage = useCallback(() => {
|
||||
dismissToasts(pageItems.map(i => i.id));
|
||||
}, [dismissToasts, pageItems]);
|
||||
|
||||
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}
|
||||
title={`현재 화면 알림 ${pageItems.length}건 닫기`}
|
||||
>
|
||||
알림창닫기 ({pageItems.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-dismiss-all"
|
||||
onClick={dismissAllToasts}
|
||||
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 ? 260 : 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>
|
||||
|
||||
{!compact && <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>
|
||||
{!compact && (
|
||||
<>
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
<button type="button" className="tsn-action-btn" onClick={onList}>
|
||||
목록
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{compact && (
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeSignalSnackbar;
|
||||
Reference in New Issue
Block a user