goldenChat base source add
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 실시간 매매 시그널 알림 Context
|
||||
*
|
||||
* - STOMP 수신 시 토스트 스택(미확인)에 추가
|
||||
* - 알림 목록·읽음 상태는 localStorage + 서버 trade-signals 이력 병합
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { TradeSignalInfo } from '../components/TradeAlertModal';
|
||||
import { loadTradeSignals, type TradeSignalDto } from '../utils/backendApi';
|
||||
import {
|
||||
normalizeTradeAlertSoundId,
|
||||
playTradeAlertSound,
|
||||
type TradeAlertSoundId,
|
||||
} from '../utils/tradeAlertSound';
|
||||
|
||||
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
||||
const MAX_HISTORY = 300;
|
||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||
const MAX_TOAST_QUEUE = 50;
|
||||
|
||||
export interface TradeNotificationItem extends TradeSignalInfo {
|
||||
/** 클라이언트 고유 키 (market:time:signal) */
|
||||
id: string;
|
||||
dbId?: number;
|
||||
isRead: boolean;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface TradeNotificationContextValue {
|
||||
/** 우측 스택에 표시 중인 미확인 알림 */
|
||||
toastNotifications: TradeNotificationItem[];
|
||||
/** 전체 이력 (목록 화면) */
|
||||
allNotifications: TradeNotificationItem[];
|
||||
unreadCount: number;
|
||||
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
/** 지정 id 알림만 닫기 (현재 페이지 등) */
|
||||
dismissToasts: (ids: string[]) => void;
|
||||
dismissAllToasts: () => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
refreshHistory: () => Promise<void>;
|
||||
detailSignal: TradeSignalInfo | null;
|
||||
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
||||
detailCentered: boolean;
|
||||
openDetail: (item: TradeNotificationItem, options?: { centered?: boolean }) => void;
|
||||
closeDetail: () => void;
|
||||
}
|
||||
|
||||
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
|
||||
|
||||
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
|
||||
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
|
||||
}
|
||||
|
||||
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
|
||||
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
|
||||
return {
|
||||
id,
|
||||
dbId: dto.id,
|
||||
market: dto.market,
|
||||
signalType: dto.signalType,
|
||||
price: dto.price,
|
||||
candleTime: dto.candleTime,
|
||||
strategyName: dto.strategyName,
|
||||
strategyId: dto.strategyId,
|
||||
executionType: dto.executionType,
|
||||
candleType: dto.candleType,
|
||||
isRead: readIds.has(id),
|
||||
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function loadReadIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useTradeNotification(): TradeNotificationContextValue {
|
||||
const ctx = useContext(TradeNotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTradeNotification must be used within TradeNotificationProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** 매매 알림 팝업 ON (설정) */
|
||||
popupEnabled: boolean;
|
||||
/** 매매 알림 사운드 ON (설정) */
|
||||
soundEnabled?: boolean;
|
||||
/** 알림음 ID */
|
||||
soundId?: string;
|
||||
}
|
||||
|
||||
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||
children,
|
||||
popupEnabled,
|
||||
soundEnabled = true,
|
||||
soundId = 'bell',
|
||||
}) => {
|
||||
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
|
||||
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
|
||||
const [toastNotifications, setToastNotifications] = useState<TradeNotificationItem[]>([]);
|
||||
const [allNotifications, setAllNotifications] = useState<TradeNotificationItem[]>([]);
|
||||
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
|
||||
const [detailCentered, setDetailCentered] = useState(false);
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => allNotifications.filter(n => !n.isRead).length,
|
||||
[allNotifications],
|
||||
);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
const dtos = await loadTradeSignals();
|
||||
const items = dtos.map(d => dtoToItem(d, readIds));
|
||||
|
||||
setAllNotifications(prev => {
|
||||
const isBoot = prev.length === 0;
|
||||
const newlyArrived = items.filter(
|
||||
item => !item.isRead && !prev.some(p => p.id === item.id),
|
||||
);
|
||||
|
||||
if (!isBoot && popupEnabled && newlyArrived.length > 0) {
|
||||
setToastNotifications(tprev => {
|
||||
const map = new Map(tprev.map(t => [t.id, t]));
|
||||
for (const n of newlyArrived) map.set(n.id, n);
|
||||
return [...map.values()].slice(0, MAX_TOAST_QUEUE);
|
||||
});
|
||||
if (soundEnabled) {
|
||||
playTradeAlertSound(alertSoundId);
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<string, TradeNotificationItem>();
|
||||
for (const n of items) map.set(n.id, n);
|
||||
for (const n of prev) {
|
||||
if (!map.has(n.id)) map.set(n.id, n);
|
||||
}
|
||||
return [...map.values()]
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||
.slice(0, MAX_HISTORY);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[TradeNotification] 이력 로드 실패:', e);
|
||||
}
|
||||
}, [readIds, popupEnabled, soundEnabled, alertSoundId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHistory();
|
||||
}, [refreshHistory]);
|
||||
|
||||
const markAsRead = useCallback((id: string) => {
|
||||
setReadIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
saveReadIds(next);
|
||||
return next;
|
||||
});
|
||||
setToastNotifications(prev => prev.filter(n => n.id !== id));
|
||||
setAllNotifications(prev =>
|
||||
prev.map(n => (n.id === id ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
||||
const id = makeId(signal);
|
||||
// 실시간 재수신 시 이전에 읽음 처리된 동일 키도 다시 미확인으로
|
||||
setReadIds(prev => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
saveReadIds(next);
|
||||
return next;
|
||||
});
|
||||
const item: TradeNotificationItem = {
|
||||
...signal,
|
||||
id,
|
||||
dbId: signal.dbId,
|
||||
candleType: signal.candleType ?? '1m',
|
||||
isRead: false,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 배지(미확인 건수)는 팝업 설정과 무관하게 항상 갱신
|
||||
setAllNotifications(prev => {
|
||||
const existing = prev.find(n => n.id === id);
|
||||
if (existing) {
|
||||
return prev.map(n =>
|
||||
n.id === id
|
||||
? { ...n, ...item, isRead: false, receivedAt: item.receivedAt }
|
||||
: n,
|
||||
);
|
||||
}
|
||||
return [item, ...prev].slice(0, MAX_HISTORY);
|
||||
});
|
||||
|
||||
if (soundEnabled) {
|
||||
playTradeAlertSound(alertSoundId);
|
||||
}
|
||||
|
||||
if (!popupEnabled) return;
|
||||
|
||||
setToastNotifications(prev => {
|
||||
const rest = prev.filter(n => n.id !== id);
|
||||
return [item, ...rest].slice(0, MAX_TOAST_QUEUE);
|
||||
});
|
||||
}, [popupEnabled, soundEnabled, alertSoundId]);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
markAsRead(id);
|
||||
}, [markAsRead]);
|
||||
|
||||
const dismissToasts = useCallback((ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
const idSet = new Set(ids);
|
||||
setReadIds(r => {
|
||||
const next = new Set(r);
|
||||
ids.forEach(i => next.add(i));
|
||||
saveReadIds(next);
|
||||
return next;
|
||||
});
|
||||
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
||||
setAllNotifications(all =>
|
||||
all.map(n => (idSet.has(n.id) ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissAllToasts = useCallback(() => {
|
||||
setToastNotifications(prev => {
|
||||
const ids = prev.map(n => n.id);
|
||||
setReadIds(r => {
|
||||
const next = new Set(r);
|
||||
ids.forEach(i => next.add(i));
|
||||
saveReadIds(next);
|
||||
return next;
|
||||
});
|
||||
setAllNotifications(all =>
|
||||
all.map(n => (ids.includes(n.id) ? { ...n, isRead: true } : n)),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(() => {
|
||||
setAllNotifications(prev => {
|
||||
const nextRead = new Set(readIds);
|
||||
prev.forEach(n => nextRead.add(n.id));
|
||||
saveReadIds(nextRead);
|
||||
setReadIds(nextRead);
|
||||
return prev.map(n => ({ ...n, isRead: true }));
|
||||
});
|
||||
setToastNotifications([]);
|
||||
}, [readIds]);
|
||||
|
||||
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
|
||||
setDetailCentered(options?.centered ?? false);
|
||||
setDetailSignal({
|
||||
market: item.market,
|
||||
signalType: item.signalType,
|
||||
price: item.price,
|
||||
candleTime: item.candleTime,
|
||||
strategyName: item.strategyName,
|
||||
strategyId: item.strategyId,
|
||||
executionType: item.executionType,
|
||||
candleType: item.candleType,
|
||||
});
|
||||
markAsRead(item.id);
|
||||
}, [markAsRead]);
|
||||
|
||||
const closeDetail = useCallback(() => {
|
||||
setDetailSignal(null);
|
||||
setDetailCentered(false);
|
||||
}, []);
|
||||
|
||||
const value: TradeNotificationContextValue = {
|
||||
toastNotifications,
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
dismissToast,
|
||||
dismissToasts,
|
||||
dismissAllToasts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
refreshHistory,
|
||||
detailSignal,
|
||||
detailCentered,
|
||||
openDetail,
|
||||
closeDetail,
|
||||
};
|
||||
|
||||
return (
|
||||
<TradeNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</TradeNotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user