goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -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>
);
};