앱 수정

This commit is contained in:
Macbook
2026-05-28 15:37:02 +09:00
parent 3503ef33f5
commit f64dc1e983
21 changed files with 1434 additions and 755 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.goldenchart.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 2
versionName "1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+63 -36
View File
@@ -1,14 +1,16 @@
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { Capacitor } from '@capacitor/core';
import { StatusBar, Style } from '@capacitor/status-bar';
import { SplashScreen } from '@capacitor/splash-screen';
import { initStorage } from './lib/shared';
import { SplashScreen as CapSplashScreen } from '@capacitor/splash-screen';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
import TabBar, { type TabId } from './components/TabBar';
import { initFcmPush, type FcmPayload } from './services/fcm';
import LiveSignalBridge from './components/LiveSignalBridge';
import LoginScreen from './screens/LoginScreen';
import '@frontend/styles/splashScreen.css';
import './theme/global.css';
const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen'));
@@ -33,35 +35,21 @@ function ToastStack() {
);
}
function AppInner() {
const { tab, setTab, openVirtualFocus } = useNavigation();
function MainApp() {
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
const { settings, isLoaded } = useAppSettings();
const { sessionKey } = useAuth();
const { settings, isLoaded } = useAppSettings(sessionKey);
const defaults = resolveAppDefaults(settings);
const [ready, setReady] = useState(false);
useEffect(() => {
void (async () => {
await initStorage();
if (Capacitor.isNativePlatform()) {
try {
await StatusBar.setStyle({ style: Style.Dark });
await StatusBar.setBackgroundColor({ color: '#0f0f23' });
await SplashScreen.hide();
} catch { /* web */ }
}
setReady(true);
})();
}, []);
useEffect(() => {
if (!isLoaded || !defaults.fcmPushEnabled) return;
void initFcmPush({
onForeground: (title, body, data) => {
onForeground: (_title, _body, data) => {
if (data?.market && data?.signalType) {
addNotification({
market: data.market,
signalType: data.signalType,
signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY',
price: Number(data.price) || 0,
candleTime: Math.floor(Date.now() / 1000),
dbId: data.signalId ? Number(data.signalId) : undefined,
@@ -85,9 +73,10 @@ function AppInner() {
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
}, [defaults.theme]);
if (!ready) {
return <div className="loading-center">GoldenChart</div>;
}
useEffect(() => {
if (tab === 'virtual') goNotifyList();
else if (tab === 'notifications') goVirtualList();
}, [tab, goVirtualList, goNotifyList]);
const screens: Record<TabId, React.ReactNode> = {
virtual: <VirtualTradingScreen />,
@@ -105,21 +94,59 @@ function AppInner() {
{screens[tab]}
</Suspense>
</main>
<TabBar
active={tab}
onChange={setTab}
unreadCount={unreadCount}
/>
<TabBar active={tab} onChange={setTab} unreadCount={unreadCount} />
</div>
);
}
function AppRoot() {
const {
authReady,
isAppEntered,
handleLoginSuccess,
handleGuestEnter,
} = useAuth();
const [nativeReady, setNativeReady] = useState(false);
useEffect(() => {
void (async () => {
if (Capacitor.isNativePlatform()) {
try {
await StatusBar.setStyle({ style: Style.Dark });
await StatusBar.setBackgroundColor({ color: '#0f0f23' });
await CapSplashScreen.hide();
} catch { /* web dev */ }
}
setNativeReady(true);
})();
}, []);
if (!authReady || !nativeReady) {
return <div className="loading-center">GoldenChart</div>;
}
if (!isAppEntered) {
return (
<LoginScreen
onLoginSuccess={handleLoginSuccess}
onGuest={handleGuestEnter}
/>
);
}
return (
<NavigationProvider>
<TradeNotificationProvider soundEnabled popupEnabled={false}>
<MainApp />
</TradeNotificationProvider>
</NavigationProvider>
);
}
export default function App() {
return (
<NavigationProvider>
<TradeNotificationProvider soundEnabled={true}>
<AppInner />
</TradeNotificationProvider>
</NavigationProvider>
<AuthProvider>
<AppRoot />
</AuthProvider>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
interface Props {
title: string;
subtitle?: string;
onBack: () => void;
right?: React.ReactNode;
}
export default function MobileStackHeader({ title, subtitle, onBack, right }: Props) {
return (
<header className="screen-header mobile-stack-header">
<button type="button" className="mobile-back-btn" onClick={onBack} aria-label="목록으로">
</button>
<div className="mobile-stack-header-titles">
<h1 className="screen-title mobile-stack-title">{title}</h1>
{subtitle && <p className="text-muted mobile-stack-subtitle">{subtitle}</p>}
</div>
{right && <div className="mobile-stack-header-right">{right}</div>}
</header>
);
}
+136
View File
@@ -0,0 +1,136 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
clearAuthSession,
fetchAuthMe,
getAuthSession,
initStorage,
setAuthSession,
type AuthSession,
type LoginResponse,
} from '../lib/shared';
import { invalidateAppSettingsCache } from '../hooks/useAppSettings';
function normalizeRole(role: string): AuthSession['role'] {
return role === 'ADMIN' ? 'ADMIN' : 'USER';
}
function loginToSession(res: LoginResponse): AuthSession {
return {
userId: res.userId,
username: res.username,
displayName: res.displayName ?? res.username,
role: normalizeRole(res.role),
};
}
interface AuthContextValue {
authReady: boolean;
authUser: AuthSession | null;
guestMode: boolean;
sessionKey: number;
isAppEntered: boolean;
handleLoginSuccess: (res: LoginResponse) => void;
handleGuestEnter: () => void;
handleLogout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authReady, setAuthReady] = useState(false);
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
const [guestMode, setGuestMode] = useState(false);
const [sessionKey, setSessionKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await initStorage();
const stored = getAuthSession();
if (stored) {
try {
const me = await fetchAuthMe();
if (me && !cancelled) {
const session = loginToSession(me);
setAuthSession(session);
setAuthUser(session);
setGuestMode(false);
setSessionKey(k => k + 1);
} else if (!cancelled) {
await clearAuthSession();
}
} catch {
if (!cancelled) {
setAuthUser(stored);
setGuestMode(false);
}
}
}
if (!cancelled) setAuthReady(true);
})();
return () => { cancelled = true; };
}, []);
const handleLoginSuccess = useCallback((res: LoginResponse) => {
const session = loginToSession(res);
setAuthSession(session);
setAuthUser(session);
setGuestMode(false);
invalidateAppSettingsCache();
setSessionKey(k => k + 1);
}, []);
const handleGuestEnter = useCallback(() => {
void clearAuthSession();
setAuthUser(null);
setGuestMode(true);
invalidateAppSettingsCache();
setSessionKey(k => k + 1);
}, []);
const handleLogout = useCallback(async () => {
await clearAuthSession();
setAuthUser(null);
setGuestMode(false);
invalidateAppSettingsCache();
setSessionKey(k => k + 1);
}, []);
const value = useMemo<AuthContextValue>(() => ({
authReady,
authUser,
guestMode,
sessionKey,
isAppEntered: authUser != null || guestMode,
handleLoginSuccess,
handleGuestEnter,
handleLogout,
}), [
authReady,
authUser,
guestMode,
sessionKey,
handleLoginSuccess,
handleGuestEnter,
handleLogout,
]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+93 -7
View File
@@ -1,30 +1,116 @@
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
import type { TabId } from '../components/TabBar';
export type VirtualView = 'list' | 'detail' | 'trade' | 'history';
export type NotifyView = 'list' | 'detail' | 'trade';
export type TradeSide = 'BUY' | 'SELL';
export interface VirtualNavState {
view: VirtualView;
market: string | null;
tradeSide: TradeSide | null;
}
export interface NotifyNavState {
view: NotifyView;
notifyId: string | null;
market: string | null;
tradeSide: TradeSide | null;
}
interface NavContextValue {
tab: TabId;
setTab: (t: TabId) => void;
focusMarket: string | null;
virtualNav: VirtualNavState;
goVirtualList: () => void;
goVirtualDetail: (market: string) => void;
goVirtualTrade: (market: string, side: TradeSide) => void;
goVirtualHistory: (market: string) => void;
/** @deprecated use goVirtualDetail */
openVirtualFocus: (market: string) => void;
clearVirtualFocus: () => void;
notifyNav: NotifyNavState;
goNotifyList: () => void;
goNotifyDetail: (notifyId: string, market: string) => void;
goNotifyTrade: (market: string, side: TradeSide) => void;
}
const defaultVirtual: VirtualNavState = { view: 'list', market: null, tradeSide: null };
const defaultNotify: NotifyNavState = { view: 'list', notifyId: null, market: null, tradeSide: null };
const NavContext = createContext<NavContextValue | null>(null);
export function NavigationProvider({ children }: { children: React.ReactNode }) {
const [tab, setTab] = useState<TabId>('virtual');
const [focusMarket, setFocusMarket] = useState<string | null>(null);
const [virtualNav, setVirtualNav] = useState<VirtualNavState>(defaultVirtual);
const [notifyNav, setNotifyNav] = useState<NotifyNavState>(defaultNotify);
const openVirtualFocus = useCallback((market: string) => {
setFocusMarket(market);
const goVirtualList = useCallback(() => {
setVirtualNav(defaultVirtual);
}, []);
const goVirtualDetail = useCallback((market: string) => {
setVirtualNav({ view: 'detail', market, tradeSide: null });
setTab('virtual');
}, []);
const clearVirtualFocus = useCallback(() => setFocusMarket(null), []);
const goVirtualTrade = useCallback((market: string, side: TradeSide) => {
setVirtualNav({ view: 'trade', market, tradeSide: side });
setTab('virtual');
}, []);
const goVirtualHistory = useCallback((market: string) => {
setVirtualNav({ view: 'history', market, tradeSide: null });
setTab('virtual');
}, []);
const openVirtualFocus = goVirtualDetail;
const clearVirtualFocus = goVirtualList;
const goNotifyList = useCallback(() => {
setNotifyNav(defaultNotify);
}, []);
const goNotifyDetail = useCallback((notifyId: string, market: string) => {
setNotifyNav({ view: 'detail', notifyId, market, tradeSide: null });
setTab('notifications');
}, []);
const goNotifyTrade = useCallback((market: string, side: TradeSide) => {
setNotifyNav({ view: 'trade', notifyId: null, market, tradeSide: side });
setTab('notifications');
}, []);
const value = useMemo(
() => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }),
[tab, focusMarket, openVirtualFocus, clearVirtualFocus],
() => ({
tab,
setTab,
virtualNav,
goVirtualList,
goVirtualDetail,
goVirtualTrade,
goVirtualHistory,
openVirtualFocus,
clearVirtualFocus,
notifyNav,
goNotifyList,
goNotifyDetail,
goNotifyTrade,
}),
[
tab,
virtualNav,
notifyNav,
goVirtualList,
goVirtualDetail,
goVirtualTrade,
goVirtualHistory,
openVirtualFocus,
clearVirtualFocus,
goNotifyList,
goNotifyDetail,
goNotifyTrade,
],
);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
+6 -216
View File
@@ -1,216 +1,6 @@
import {
deleteAllTradeSignals,
deleteTradeSignal,
deleteTradeSignalsBatch,
loadTradeSignals,
type TradeSignalDto,
} from '../lib/shared';
import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
export interface TradeSignalInfo {
market: string;
signalType: string;
price: number;
candleTime: number;
strategyName?: string;
strategyId?: number;
executionType?: string;
candleType?: string;
}
export interface TradeNotificationItem extends TradeSignalInfo {
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;
dismissAllToasts: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
deleteNotification: (id: string) => Promise<void>;
deleteAllNotifications: () => Promise<void>;
refreshHistory: () => Promise<void>;
}
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
}
function loadReadIds(): Set<string> {
return new Set(getUiPreferences().tradeNotifications?.readIds ?? []);
}
function saveReadIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
}
function loadHiddenIds(): Set<string> {
return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []);
}
function saveHiddenIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
}
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,
};
}
export function useTradeNotification() {
const ctx = useContext(TradeNotificationContext);
if (!ctx) throw new Error('useTradeNotification requires provider');
return ctx;
}
export function TradeNotificationProvider({
children,
soundEnabled = true,
}: {
children: React.ReactNode;
soundEnabled?: boolean;
}) {
const [toasts, setToasts] = useState<TradeNotificationItem[]>([]);
const [all, setAll] = useState<TradeNotificationItem[]>([]);
const readRef = useRef(loadReadIds());
const hiddenRef = useRef(loadHiddenIds());
const refreshHistory = useCallback(async () => {
readRef.current = loadReadIds();
hiddenRef.current = loadHiddenIds();
const dtos = await loadTradeSignals();
const items = (dtos ?? [])
.map(d => dtoToItem(d, readRef.current))
.filter(i => !hiddenRef.current.has(i.id))
.sort((a, b) => b.receivedAt - a.receivedAt);
setAll(items);
}, []);
useEffect(() => {
void refreshHistory();
const t = setInterval(() => void refreshHistory(), 20000);
return () => clearInterval(t);
}, [refreshHistory]);
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
const id = makeId(signal);
if (hiddenRef.current.has(id)) return;
const item: TradeNotificationItem = {
...signal,
id,
isRead: readRef.current.has(id),
receivedAt: Date.now(),
};
setAll(prev => {
const filtered = prev.filter(p => p.id !== id);
return [item, ...filtered].slice(0, 300);
});
if (!item.isRead) {
setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50));
if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(80);
}
}
}, [soundEnabled]);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
readRef.current.add(id);
saveReadIds(readRef.current);
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
}, []);
const dismissAllToasts = useCallback(() => {
setToasts(prev => {
prev.forEach(t => readRef.current.add(t.id));
saveReadIds(readRef.current);
return [];
});
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
}, []);
const markAsRead = useCallback((id: string) => {
readRef.current.add(id);
saveReadIds(readRef.current);
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const markAllAsRead = useCallback(() => {
all.forEach(n => readRef.current.add(n.id));
saveReadIds(readRef.current);
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
setToasts([]);
}, [all]);
const deleteNotification = useCallback(async (id: string) => {
const item = all.find(n => n.id === id);
hiddenRef.current.add(id);
saveHiddenIds(hiddenRef.current);
if (item?.dbId) await deleteTradeSignal(item.dbId);
setAll(prev => prev.filter(n => n.id !== id));
setToasts(prev => prev.filter(t => t.id !== id));
}, [all]);
const deleteAllNotifications = useCallback(async () => {
await deleteAllTradeSignals();
all.forEach(n => hiddenRef.current.add(n.id));
saveHiddenIds(hiddenRef.current);
setAll([]);
setToasts([]);
}, [all]);
const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]);
const value = useMemo(
() => ({
toastNotifications: toasts,
allNotifications: all,
unreadCount,
addNotification,
dismissToast,
dismissAllToasts,
markAsRead,
markAllAsRead,
deleteNotification,
deleteAllNotifications,
refreshHistory,
}),
[toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory],
);
return (
<TradeNotificationContext.Provider value={value}>{children}</TradeNotificationContext.Provider>
);
}
/** 웹 frontend와 동일한 알림 Context (STOMP·trade-signals·uiPreferences) */
export {
TradeNotificationProvider,
useTradeNotification,
type TradeNotificationItem,
} from '@frontend/contexts/TradeNotificationContext';
+12
View File
@@ -0,0 +1,12 @@
/** 웹 SplashScreen과 동일한 로그인 진입 화면 */
import SplashScreen from '@frontend/components/SplashScreen';
import type { LoginResponse } from '../lib/shared';
interface Props {
onLoginSuccess: (res: LoginResponse) => void;
onGuest: () => void;
}
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
return <SplashScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
}
@@ -0,0 +1,57 @@
import React from 'react';
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
import type { TradeSide } from '../../contexts/NavigationContext';
import {
buildSignalDetailRows,
formatSignalPrice,
getSignalHeadline,
} from '@frontend/utils/tradeSignalDisplay';
import MobileStackHeader from '../../components/MobileStackHeader';
interface Props {
item: TradeNotificationItem;
onBack: () => void;
onTrade: (side: TradeSide) => void;
onGoVirtual: () => void;
}
export default function NotificationDetailScreen({ item, onBack, onTrade, onGoVirtual }: Props) {
const rows = buildSignalDetailRows(item);
const side = item.signalType === 'SELL' ? 'SELL' : 'BUY';
return (
<div className="screen stack-screen">
<MobileStackHeader title="알림 상세" subtitle={getSignalHeadline(item)} onBack={onBack} />
<div className="stack-screen-body">
<div className="mobile-list-row-actions" style={{ marginBottom: 16 }}>
<button type="button" className="btn-secondary mobile-action-btn" onClick={onGoVirtual}>
</button>
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
</button>
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
</button>
</div>
<div className="card" style={{ marginBottom: 16, textAlign: 'center', padding: 20 }}>
<div className={`${side === 'BUY' ? 'text-green' : 'text-red'}`} style={{ fontSize: 28, fontWeight: 800 }}>
{side === 'BUY' ? '매수' : '매도'}
</div>
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 8 }}>{formatSignalPrice(item.price)}</div>
</div>
<div className="stack-list">
{rows.map(row => (
<div key={row.label} className="card mobile-detail-row">
<span className="text-muted" style={{ fontSize: 12 }}>{row.label}</span>
<span style={{ fontSize: 14, fontWeight: 500, marginTop: 4, display: 'block' }}>{row.value}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import React from 'react';
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
import type { TradeSide } from '../../contexts/NavigationContext';
import { getMarketDisplayLine } from '@frontend/utils/tradeSignalDisplay';
interface Props {
item: TradeNotificationItem;
onDetail: () => void;
onTrade: (side: TradeSide) => void;
onDelete: () => void;
}
export default function NotificationListRow({ item, onDetail, onTrade, onDelete }: Props) {
const { primary, secondary } = getMarketDisplayLine(item.market);
const isBuy = item.signalType === 'BUY';
return (
<article
className={`mobile-list-row card${item.isRead ? '' : ' mobile-list-row--unread'}`}
style={{ borderLeft: `3px solid ${isBuy ? 'var(--gc-green)' : 'var(--gc-red)'}` }}
>
<div className="mobile-list-row-main">
<div className="mobile-list-row-info">
<div className="mobile-list-row-title">
<span className={isBuy ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
{isBuy ? '매수' : '매도'}
</span>
<span style={{ marginLeft: 6 }}>{primary}</span>
</div>
<div className="text-muted mobile-list-row-meta">
{secondary} · {item.price?.toLocaleString()} · {new Date(item.receivedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
{item.strategyName && (
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{item.strategyName}</div>
)}
</div>
<button type="button" className="icon-btn icon-btn--danger" onClick={onDelete} aria-label="삭제"></button>
</div>
<div className="mobile-list-row-actions">
<button type="button" className="btn-secondary mobile-action-btn" onClick={onDetail}>
</button>
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
</button>
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
</button>
</div>
</article>
);
}
@@ -1,8 +1,21 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
import { useNavigation } from '../../contexts/NavigationContext';
import { useAuth } from '../../contexts/AuthContext';
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import NotificationListRow from './NotificationListRow';
import NotificationDetailScreen from './NotificationDetailScreen';
import VirtualTradeScreen from '../virtual/VirtualTradeScreen';
export default function NotificationsScreen() {
const {
notifyNav,
goNotifyList,
goNotifyDetail,
goNotifyTrade,
goVirtualDetail,
} = useNavigation();
const { sessionKey } = useAuth();
const {
allNotifications,
unreadCount,
@@ -12,8 +25,14 @@ export default function NotificationsScreen() {
deleteAllNotifications,
refreshHistory,
} = useTradeNotification();
const { openVirtualFocus } = useNavigation();
const [refreshing, setRefreshing] = React.useState(false);
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
const [refreshing, setRefreshing] = useState(false);
const selectedItem = useMemo(
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
[allNotifications, notifyNav.notifyId],
);
const onRefresh = async () => {
setRefreshing(true);
@@ -21,13 +40,42 @@ export default function NotificationsScreen() {
setRefreshing(false);
};
if (notifyNav.view === 'detail' && selectedItem) {
return (
<NotificationDetailScreen
item={selectedItem}
onBack={() => {
markAsRead(selectedItem.id);
goNotifyList();
}}
onTrade={side => goNotifyTrade(selectedItem.market, side)}
onGoVirtual={() => {
markAsRead(selectedItem.id);
goVirtualDetail(selectedItem.market);
}}
/>
);
}
if (notifyNav.view === 'trade' && notifyNav.market && notifyNav.tradeSide) {
return (
<VirtualTradeScreen
market={notifyNav.market}
side={notifyNav.tradeSide}
summary={summary}
onBack={goNotifyList}
onOrderDone={refreshPaperData}
/>
);
}
return (
<div className="screen">
<header className="screen-header">
<h1 className="screen-title"> {unreadCount > 0 && `(${unreadCount})`}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '새로고침'}</button>
<button type="button" className="chip" onClick={markAllAsRead}> </button>
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : ''}</button>
<button type="button" className="chip" onClick={markAllAsRead}></button>
</div>
</header>
@@ -37,47 +85,21 @@ export default function NotificationsScreen() {
<p> </p>
</div>
) : (
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="stack-list" style={{ padding: '0 16px' }}>
{allNotifications.map(item => (
<div
<NotificationListRow
key={item.id}
className="card"
style={{
padding: 14,
opacity: item.isRead ? 0.65 : 1,
borderLeft: `3px solid ${item.signalType === 'BUY' ? 'var(--gc-green)' : 'var(--gc-red)'}`,
}}
onClick={() => {
item={item}
onDetail={() => {
markAsRead(item.id);
openVirtualFocus(item.market);
goNotifyDetail(item.id, item.market);
}}
role="button"
tabIndex={0}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<span className={item.signalType === 'BUY' ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
{item.signalType === 'BUY' ? '매수' : '매도'}
</span>
<span style={{ marginLeft: 8, fontWeight: 600 }}>{item.market}</span>
</div>
<button
type="button"
className="chip"
style={{ minHeight: 28, padding: '4px 8px', color: 'var(--gc-red)' }}
onClick={e => { e.stopPropagation(); void deleteNotification(item.id); }}
>
</button>
</div>
<div style={{ fontSize: 14, marginTop: 4 }}>{item.price?.toLocaleString()}</div>
{item.strategyName && (
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{item.strategyName}</div>
)}
<div className="text-muted" style={{ fontSize: 10, marginTop: 4 }}>
{new Date(item.receivedAt).toLocaleString('ko-KR')}
</div>
</div>
onTrade={side => {
markAsRead(item.id);
goNotifyTrade(item.market, side);
}}
onDelete={() => void deleteNotification(item.id)}
/>
))}
</div>
)}
+41 -56
View File
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
loginUser,
resetPaperAccount,
sendFcmTest,
loadFcmStatus,
@@ -8,23 +7,19 @@ import {
getStoredDeviceId,
type AppSettingsDto,
} from '../../lib/shared';
import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared';
import { useAuth } from '../../contexts/AuthContext';
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
import { initFcmPush, checkPushPermission } from '../../services/fcm';
import { API_BASE } from '../../lib/shared';
export default function SettingsScreen() {
const { settings, save, isLoaded } = useAppSettings();
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
const { settings, save, isLoaded } = useAppSettings(sessionKey);
const defaults = resolveAppDefaults(settings);
const session = getAuthSession();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [apiUrl, setApiUrl] = useState(API_BASE);
const [fcmAvailable, setFcmAvailable] = useState(false);
const [pushPerm, setPushPerm] = useState<string>('prompt');
const [loginError, setLoginError] = useState('');
useEffect(() => {
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
@@ -33,34 +28,8 @@ export default function SettingsScreen() {
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
const handleLogin = async () => {
setLoginError('');
try {
const res = await loginUser(username, password);
setAuthSession({
userId: res.userId,
username: res.username,
displayName: res.displayName ?? res.username,
role: res.role === 'ADMIN' ? 'ADMIN' : 'USER',
});
setUsername('');
setPassword('');
} catch (e) {
setLoginError(e instanceof Error ? e.message : '로그인 실패');
}
};
const handleLogout = async () => {
await clearAuthSession();
window.location.reload();
};
const handleFcmToggle = async (enabled: boolean) => {
patch({ fcmPushEnabled: enabled });
if (enabled) {
await initFcmPush();
setPushPerm(await checkPushPermission());
}
const onLogout = () => {
void handleLogout();
};
if (!isLoaded) return <div className="loading-center"> </div>;
@@ -71,6 +40,33 @@ export default function SettingsScreen() {
<h1 className="screen-title"></h1>
</header>
<SettingGroup title="계정">
{authUser ? (
<>
<SettingRow label="로그인">
<span>{authUser.displayName} ({authUser.username})</span>
</SettingRow>
<SettingRow label="역할">
<span className="text-muted">{authUser.role}</span>
</SettingRow>
</>
) : (
<SettingRow label="모드">
<span className="text-muted"> ( )</span>
</SettingRow>
)}
<SettingRow label="로그아웃">
<button type="button" className="btn-secondary" onClick={onLogout}>
{authUser ? '로그아웃' : '로그인 화면으로'}
</button>
</SettingRow>
{!guestMode && authUser && (
<p className="text-muted" style={{ padding: '0 16px 8px', fontSize: 12, lineHeight: 1.5 }}>
·· (exdev) DB를 .
</p>
)}
</SettingGroup>
<SettingGroup title="일반">
<SettingRow label="테마" description="앱 색상">
<select
@@ -138,7 +134,14 @@ export default function SettingsScreen() {
<SettingGroup title="FCM 푸시">
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
<Toggle checked={!!defaults.fcmPushEnabled} onChange={v => void handleFcmToggle(v)} label="FCM 푸시" />
<Toggle
checked={!!defaults.fcmPushEnabled}
onChange={v => {
patch({ fcmPushEnabled: v });
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
}}
label="FCM 푸시"
/>
</SettingRow>
<SettingRow label="권한 상태">
<span className="text-muted">{pushPerm}</span>
@@ -148,7 +151,7 @@ export default function SettingsScreen() {
</SettingRow>
</SettingGroup>
<SettingGroup title="네트워크 · 계정">
<SettingGroup title="네트워크">
<SettingRow label="API URL">
<input
value={apiUrl}
@@ -162,24 +165,6 @@ export default function SettingsScreen() {
<SettingRow label="Device ID">
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
</SettingRow>
{session ? (
<SettingRow label={`${session.displayName} (${session.username})`}>
<button type="button" className="btn-secondary" onClick={() => void handleLogout()}></button>
</SettingRow>
) : (
<>
<SettingRow label="아이디">
<input value={username} onChange={e => setUsername(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
</SettingRow>
<SettingRow label="비밀번호">
<input type="password" value={password} onChange={e => setPassword(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
</SettingRow>
<SettingRow label="로그인">
<button type="button" className="btn-primary" style={{ padding: '8px 16px' }} onClick={() => void handleLogin()}></button>
</SettingRow>
{loginError && <div className="text-red" style={{ padding: '8px 16px', fontSize: 12 }}>{loginError}</div>}
</>
)}
</SettingGroup>
</div>
);
+35 -17
View File
@@ -2,7 +2,9 @@ import React from 'react';
import type { StrategyDto } from '../../lib/shared';
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
import type { TradeSide } from '../../contexts/NavigationContext';
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
import MobileStackHeader from '../../components/MobileStackHeader';
interface Props {
target: VirtualTargetItem;
@@ -11,7 +13,8 @@ interface Props {
snapshot?: VirtualIndicatorSnapshot;
liveConnected: boolean;
onBack: () => void;
onOpenTrade: () => void;
onTrade: (side: TradeSide) => void;
onHistory: () => void;
}
export default function VirtualFocusScreen({
@@ -19,21 +22,34 @@ export default function VirtualFocusScreen({
snapshot,
liveConnected,
onBack,
onOpenTrade,
onTrade,
onHistory,
}: Props) {
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
return (
<div className="screen focus-screen">
<header className="screen-header">
<button type="button" onClick={onBack} style={{ fontSize: 24, minWidth: 44 }}></button>
<h1 className="screen-title" style={{ flex: 1 }}>{target.market}</h1>
<button type="button" className="btn-primary" onClick={onOpenTrade}></button>
</header>
<div className="screen stack-screen">
<MobileStackHeader
title={target.koreanName ?? target.market}
subtitle={target.market}
onBack={onBack}
/>
<div className="stack-screen-body">
<div className="mobile-list-row-actions" style={{ marginBottom: 16 }}>
<button type="button" className="btn-secondary mobile-action-btn" onClick={onHistory}>
</button>
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
</button>
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
</button>
</div>
<div style={{ padding: 16 }}>
<div className="card" style={{ marginBottom: 16, textAlign: 'center' }}>
<div className="text-muted" style={{ fontSize: 12 }}></div>
<div style={{ fontSize: 48, fontWeight: 700, color: 'var(--gc-accent)' }}>
@@ -56,16 +72,18 @@ export default function VirtualFocusScreen({
</div>
<h3 style={{ fontSize: 14, marginBottom: 8 }}> </h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="stack-list">
{(snapshot?.rows ?? []).map(row => (
<div key={row.id} className="card" style={{ padding: 10, display: 'flex', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
<div key={row.id} className="card mobile-history-row">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
</div>
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : 'text-muted'}>
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
</span>
</div>
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : 'text-muted'}>
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
</span>
</div>
))}
</div>
@@ -0,0 +1,41 @@
import React from 'react';
import type { PaperTradeDto } from '../../lib/shared';
import MobileStackHeader from '../../components/MobileStackHeader';
interface Props {
market: string;
trades: PaperTradeDto[];
onBack: () => void;
}
export default function VirtualHistoryScreen({ market, trades, onBack }: Props) {
const filtered = trades.filter(t => t.symbol === market).slice(0, 50);
return (
<div className="screen stack-screen">
<MobileStackHeader title="거래 내역" subtitle={market} onBack={onBack} />
<div className="stack-screen-body">
{filtered.length === 0 ? (
<div className="empty-state">
<h3> </h3>
<p> .</p>
</div>
) : (
<div className="stack-list">
{filtered.map(t => (
<div key={t.id} className="card mobile-history-row">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
{t.side === 'BUY' ? '매수' : '매도'}
</span>
<span style={{ fontWeight: 600 }}>{t.quantity} @ {t.price?.toLocaleString()}</span>
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 6 }}>{t.createdAt ?? ''}</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -1,130 +0,0 @@
import React from 'react';
import type { StrategyDto } from '../../lib/shared';
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
interface Props {
target: VirtualTargetItem;
session: VirtualSessionConfig;
strategies: StrategyDto[];
snapshot?: VirtualIndicatorSnapshot;
viewMode: VirtualCardViewMode;
liveFlash?: boolean;
liveStatus?: VirtualLiveStatus;
onFocus: () => void;
onTrade: () => void;
onRemove: () => void;
onTogglePin: () => void;
onStrategyChange: (id: number | null) => void;
onCandleTypeChange: (ct: string) => void;
}
export default function VirtualTargetCardMobile({
target,
session,
strategies,
snapshot,
viewMode,
liveFlash,
liveStatus,
onFocus,
onTrade,
onRemove,
onTogglePin,
onStrategyChange,
}: Props) {
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
return (
<article
className={`v-card card${liveFlash ? ' flash' : ''}`}
onClick={onFocus}
role="button"
tabIndex={0}
>
<div className="v-card-top">
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{target.koreanName ?? target.market}</div>
<div className="text-muted" style={{ fontSize: 11 }}>{target.market}</div>
</div>
<div style={{ textAlign: 'right' }}>
<span className={`live-dot ${liveStatus ?? 'idle'}`} />
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--gc-accent)' }}>{(snapshot?.matchRate ?? buyPct).toFixed(0)}%</div>
</div>
</div>
<div className="v-card-eq">
<div className="eq-bar">
<div className="eq-fill buy" style={{ width: `${buyPct}%` }} />
</div>
<div className="eq-bar">
<div className="eq-fill sell" style={{ width: `${sellPct}%` }} />
</div>
</div>
<div className="v-card-meta text-muted" style={{ fontSize: 11 }}>
{strategyName} · {snapshot?.timeframe ?? '—'}
</div>
{viewMode === 'detail' && snapshot?.rows && (
<div className="v-card-conditions">
{snapshot.rows.slice(0, 6).map(row => (
<div key={row.id} className="cond-row">
<span>{row.displayName}</span>
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : ''}>
{row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'}
</span>
</div>
))}
</div>
)}
<div className="v-card-actions" onClick={e => e.stopPropagation()}>
<select
className="chip"
value={strategyId ?? ''}
onChange={e => onStrategyChange(e.target.value ? Number(e.target.value) : null)}
style={{ flex: 1, fontSize: 11 }}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={onTrade}></button>
<button type="button" className="btn-secondary" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onTogglePin}>{target.pinned ? '📌' : '○'}</button>
{!target.pinned && (
<button type="button" className="btn-danger" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onRemove}></button>
)}
</div>
<style>{`
.v-card { cursor: pointer; transition: box-shadow 0.2s; }
.v-card.flash { box-shadow: 0 0 0 2px var(--gc-accent); }
.v-card-top { display: flex; justify-content: space-between; margin-bottom: 10px; }
.live-dot {
display: inline-block;
width: 8px; height: 8px; border-radius: 50%;
background: var(--gc-text-dim); margin-right: 4px;
}
.live-dot.live { background: var(--gc-green); }
.live-dot.connecting { background: var(--gc-orange); }
.live-dot.disconnected { background: var(--gc-red); }
.v-card-eq { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.eq-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; }
.eq-fill.buy { height: 100%; background: var(--gc-green); }
.eq-fill.sell { height: 100%; background: var(--gc-red); }
.v-card-conditions { margin: 8px 0; font-size: 11px; }
.cond-row { display: flex; justify-content: space-between; padding: 2px 0; }
.v-card-actions { display: flex; gap: 6px; margin-top: 10px; align-items: center; }
`}</style>
</article>
);
}
@@ -0,0 +1,70 @@
import React from 'react';
import type { StrategyDto } from '../../lib/shared';
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
import type { VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
import type { TradeSide } from '../../contexts/NavigationContext';
interface Props {
target: VirtualTargetItem;
globalStrategyId: number | null;
strategies: StrategyDto[];
snapshot?: VirtualIndicatorSnapshot;
liveStatus?: VirtualLiveStatus;
onDetail: () => void;
onTrade: (side: TradeSide) => void;
onTogglePin: () => void;
onRemove: () => void;
}
export default function VirtualTargetListRow({
target,
globalStrategyId,
strategies,
snapshot,
liveStatus,
onDetail,
onTrade,
onTogglePin,
onRemove,
}: Props) {
const strategyId = resolveVirtualTargetStrategyId(target, globalStrategyId);
const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
const matchPct = snapshot?.matchRate ?? 0;
return (
<article className="mobile-list-row card">
<div className="mobile-list-row-main">
<div className="mobile-list-row-info">
<div className="mobile-list-row-title">
<span className={`live-dot ${liveStatus ?? 'idle'}`} aria-hidden />
<span>{target.koreanName ?? target.market}</span>
</div>
<div className="text-muted mobile-list-row-meta">
{target.market} · {strategyName} · {matchPct.toFixed(0)}%
</div>
</div>
<div className="mobile-list-row-pin">
<button type="button" className="icon-btn" onClick={onTogglePin} aria-label="고정">
{target.pinned ? '📌' : '○'}
</button>
{!target.pinned && (
<button type="button" className="icon-btn icon-btn--danger" onClick={onRemove} aria-label="삭제"></button>
)}
</div>
</div>
<div className="mobile-list-row-actions">
<button type="button" className="btn-secondary mobile-action-btn" onClick={onDetail}>
</button>
<button type="button" className="btn-primary mobile-action-btn mobile-action-btn--buy" onClick={() => onTrade('BUY')}>
</button>
<button type="button" className="btn-danger mobile-action-btn" onClick={() => onTrade('SELL')}>
</button>
</div>
</article>
);
}
+32 -16
View File
@@ -5,29 +5,34 @@ import { Haptics, ImpactStyle } from '@capacitor/haptics';
interface Props {
market: string;
summary: PaperSummaryDto | null;
/** 단일 매수/매도 화면 또는 둘 다 */
side?: 'BUY' | 'SELL' | 'both';
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise<void>;
}
export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
export default function VirtualTradePanel({ market, summary, side = 'both', onOrder }: Props) {
const [qty, setQty] = useState('0.001');
const [price, setPrice] = useState('');
const [busy, setBusy] = useState(false);
const position = summary?.positions?.find(p => p.symbol === market);
const submit = async (side: 'BUY' | 'SELL') => {
const submit = async (orderSide: 'BUY' | 'SELL') => {
const q = parseFloat(qty);
const p = parseFloat(price) || 0;
if (!Number.isFinite(q) || q <= 0) return;
setBusy(true);
try {
await onOrder(side, q, p);
await onOrder(orderSide, q, p);
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
} finally {
setBusy(false);
}
};
const showBuy = side === 'both' || side === 'BUY';
const showSell = side === 'both' || side === 'SELL';
return (
<div className="trade-panel">
{position && (
@@ -37,12 +42,7 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
</div>
)}
<label style={{ display: 'block', marginBottom: 8, fontSize: 13 }}></label>
<input
type="number"
value={qty}
onChange={e => setQty(e.target.value)}
style={inputStyle}
/>
<input type="number" value={qty} onChange={e => setQty(e.target.value)} style={inputStyle} />
<label style={{ display: 'block', margin: '12px 0 8px', fontSize: 13 }}> (0=)</label>
<input
type="number"
@@ -51,13 +51,29 @@ export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
placeholder="시장가"
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
<button type="button" className="btn-primary" style={{ flex: 1, background: 'var(--gc-green)' }} disabled={busy} onClick={() => void submit('BUY')}>
</button>
<button type="button" className="btn-danger" style={{ flex: 1 }} disabled={busy} onClick={() => void submit('SELL')}>
</button>
<div className="trade-panel-actions" style={{ display: 'flex', gap: 10, marginTop: 16 }}>
{showBuy && (
<button
type="button"
className="btn-primary mobile-action-btn--buy"
style={{ flex: 1 }}
disabled={busy}
onClick={() => void submit('BUY')}
>
</button>
)}
{showSell && (
<button
type="button"
className="btn-danger"
style={{ flex: 1 }}
disabled={busy}
onClick={() => void submit('SELL')}
>
</button>
)}
</div>
</div>
);
@@ -0,0 +1,36 @@
import React from 'react';
import { placePaperOrder, type PaperSummaryDto } from '../../lib/shared';
import type { TradeSide } from '../../contexts/NavigationContext';
import MobileStackHeader from '../../components/MobileStackHeader';
import VirtualTradePanel from './VirtualTradePanel';
interface Props {
market: string;
side: TradeSide;
summary: PaperSummaryDto | null;
onBack: () => void;
onOrderDone: () => Promise<void>;
}
export default function VirtualTradeScreen({ market, side, summary, onBack, onOrderDone }: Props) {
return (
<div className="screen stack-screen">
<MobileStackHeader
title={side === 'BUY' ? '매수' : '매도'}
subtitle={market}
onBack={onBack}
/>
<div className="stack-screen-body">
<VirtualTradePanel
market={market}
summary={summary}
side={side}
onOrder={async (orderSide, qty, price) => {
await placePaperOrder({ market, side: orderSide, price, quantity: qty });
await onOrderDone();
}}
/>
</div>
</div>
);
}
+109 -233
View File
@@ -1,183 +1,103 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
loadStrategies,
placePaperOrder,
resetPaperAccount,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} from '../../lib/shared';
import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
import {
loadVirtualSession,
loadVirtualTargets,
saveVirtualSession,
saveVirtualTargets,
loadVirtualCardViewMode,
saveVirtualCardViewMode,
type VirtualSessionConfig,
type VirtualTargetItem,
type VirtualCardViewMode,
resolveTargetCandleType,
} from '@frontend/utils/virtualTradingStorage';
import {
syncVirtualTargetsToBackend,
stopVirtualLiveOnBackend,
} from '@frontend/utils/virtualLiveStrategySync';
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
import { useNavigation } from '../../contexts/NavigationContext';
import BottomSheet from '../../components/BottomSheet';
import SegmentedControl from '../../components/SegmentedControl';
import VirtualTargetCardMobile from './VirtualTargetCardMobile';
import VirtualTargetListRow from './VirtualTargetListRow';
import VirtualFocusScreen from './VirtualFocusScreen';
import VirtualTradePanel from './VirtualTradePanel';
type RightTab = 'trade' | 'history';
import VirtualTradeScreen from './VirtualTradeScreen';
import VirtualHistoryScreen from './VirtualHistoryScreen';
import { MarketSearchPanel } from '@frontend/components/MarketSearchPanel';
export default function VirtualTradingScreen() {
const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
const { settings } = useAppSettings();
const defaults = resolveAppDefaults(settings);
const {
virtualNav,
goVirtualList,
goVirtualDetail,
goVirtualTrade,
goVirtualHistory,
} = useNavigation();
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [sheetOpen, setSheetOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [newMarket, setNewMarket] = useState('');
const refreshSummary = useCallback(async () => {
const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
setSummary(s);
setTrades(t ?? []);
}, []);
useEffect(() => {
void Promise.all([
loadStrategies().then(setStrategies).catch(() => []),
refreshSummary(),
]).finally(() => setLoading(false));
}, [refreshSummary]);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
const targetRefs = useMemo(
() => targets.map(t => ({
market: t.market,
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
})),
[targets, session.globalStrategyId],
);
const snapshots = useVirtualIndicatorSnapshots(
targetRefs,
strategies as Parameters<typeof useVirtualIndicatorSnapshots>[1],
session.running,
);
const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
const liveConnected = useMemo(
() => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
[liveStatus.statusByMarket],
);
useVirtualAutoTrade({
const core = useVirtualTradingCore({ settingsSessionKey });
const {
targets,
session,
strategies,
summary,
trades,
loading,
snapshots,
enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
positions: summary?.positions,
onFilled: refreshSummary,
});
liveStatusByMarket,
handleStart,
handleStop,
handleRemoveTarget,
handleTogglePin,
handleGlobalStrategyChange,
handleExecutionTypeChange,
handlePositionModeChange,
addTarget,
reloadTargetsFromStorage,
} = core;
const toggleRunning = useCallback(async () => {
const next = { ...session, running: !session.running };
setSession(next);
if (next.running) {
await syncVirtualTargetsToBackend(targets, next, true);
} else {
await stopVirtualLiveOnBackend(targets, next);
}
}, [session, targets]);
const handleAddTarget = useCallback(() => {
const market = newMarket.trim().toUpperCase();
if (!market) return;
if (targets.some(t => t.market === market)) return;
if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
return;
}
setTargets(prev => [...prev, { market, strategyId: null }]);
setNewMarket('');
setAddSheetOpen(false);
}, [newMarket, targets, defaults.virtualTargetMaxCount]);
const handleRemoveTarget = useCallback((market: string) => {
const t = targets.find(x => x.market === market);
if (t?.pinned) return;
setTargets(prev => prev.filter(x => x.market !== market));
}, [targets]);
const handleTogglePin = useCallback(async (market: string) => {
const t = targets.find(x => x.market === market);
if (!t) return;
const pinned = !t.pinned;
setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
await persistVirtualTargetPinned(market, pinned);
}, [targets]);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const pnlPct = summary?.totalReturnPct ?? 0;
const liveConnected = Object.values(liveStatusByMarket).some(s => s === 'live' || s === 'connecting');
if (focusMarket) {
const target = targets.find(t => t.market === focusMarket);
if (target) {
return (
<VirtualFocusScreen
target={target}
session={session}
strategies={strategies}
snapshot={(() => {
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
})()}
liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
onBack={clearVirtualFocus}
onOpenTrade={() => {
setSelectedMarket(focusMarket);
setSheetOpen(true);
}}
/>
);
}
clearVirtualFocus();
const market = virtualNav.market;
const target = market ? targets.find(t => t.market === market) : undefined;
if (virtualNav.view === 'detail' && market && target) {
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
const snap = snapshots[`${market}:${sid ?? ''}`] ?? snapshots[market];
return (
<VirtualFocusScreen
target={target}
session={session}
strategies={strategies}
snapshot={snap}
liveConnected={liveConnected}
onBack={goVirtualList}
onTrade={side => goVirtualTrade(market, side)}
onHistory={() => goVirtualHistory(market)}
/>
);
}
if (virtualNav.view === 'trade' && market && virtualNav.tradeSide) {
return (
<VirtualTradeScreen
market={market}
side={virtualNav.tradeSide}
summary={summary}
onBack={goVirtualList}
onOrderDone={core.refreshPaperData}
/>
);
}
if (virtualNav.view === 'history' && market) {
return (
<VirtualHistoryScreen
market={market}
trades={trades}
onBack={goVirtualList}
/>
);
}
return (
<div className="screen virtual-screen">
<header className="screen-header">
<h1 className="screen-title"></h1>
<button type="button" className="chip" onClick={() => reloadTargetsFromStorage()} title="동기화"></button>
<button
type="button"
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
onClick={() => void toggleRunning()}
onClick={() => void (session.running ? handleStop() : handleStart())}
>
{session.running ? '■ 중지' : '▶ 시작'}
</button>
@@ -187,9 +107,7 @@ export default function VirtualTradingScreen() {
<div className="virtual-summary-row">
<div>
<div className="text-muted" style={{ fontSize: 12 }}> </div>
<div style={{ fontSize: 22, fontWeight: 700 }}>
{(summary?.totalAsset ?? 0).toLocaleString()}
</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>{(summary?.totalAsset ?? 0).toLocaleString()}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className="text-muted" style={{ fontSize: 12 }}></div>
@@ -207,7 +125,7 @@ export default function VirtualTradingScreen() {
<select
className="chip"
value={session.globalStrategyId ?? ''}
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
onChange={e => handleGlobalStrategyChange(e.target.value ? Number(e.target.value) : null)}
style={{ maxWidth: 160 }}
>
<option value=""> </option>
@@ -222,7 +140,7 @@ export default function VirtualTradingScreen() {
{ value: 'REALTIME_TICK' as const, label: '실시간' },
]}
value={session.executionType}
onChange={v => setSession(s => ({ ...s, executionType: v }))}
onChange={handleExecutionTypeChange}
/>
</div>
@@ -234,16 +152,7 @@ export default function VirtualTradingScreen() {
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
]}
value={session.positionMode}
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
/>
<SegmentedControl
size="sm"
options={[
{ value: 'summary' as const, label: '요약' },
{ value: 'detail' as const, label: '상세' },
]}
value={viewMode}
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
onChange={handlePositionModeChange}
/>
</div>
@@ -252,30 +161,30 @@ export default function VirtualTradingScreen() {
) : targets.length === 0 ? (
<div className="empty-state">
<h3> </h3>
<p>+ </p>
{guestMode ? (
<p> . .</p>
) : (
<p> ({authUser?.username}) · + </p>
)}
</div>
) : (
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{targets.map(target => {
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
const snapKey = `${target.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[target.market];
<div className="stack-list" style={{ padding: '0 16px' }}>
{targets.map(t => {
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
const snapKey = `${t.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[t.market];
return (
<VirtualTargetCardMobile
key={target.market}
target={target}
session={session}
<VirtualTargetListRow
key={t.market}
target={t}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
snapshot={snap}
viewMode={viewMode}
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
liveStatus={liveStatus.statusByMarket[target.market]}
onFocus={() => openVirtualFocus(target.market)}
onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
onRemove={() => handleRemoveTarget(target.market)}
onTogglePin={() => void handleTogglePin(target.market)}
onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
liveStatus={liveStatusByMarket[t.market]}
onDetail={() => goVirtualDetail(t.market)}
onTrade={side => goVirtualTrade(t.market, side)}
onTogglePin={() => void handleTogglePin(t.market)}
onRemove={() => handleRemoveTarget(t.market)}
/>
);
})}
@@ -284,51 +193,18 @@ export default function VirtualTradingScreen() {
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
<input
type="text"
placeholder="KRW-BTC"
value={newMarket}
onChange={e => setNewMarket(e.target.value)}
style={{
width: '100%',
padding: '12px 14px',
borderRadius: 10,
border: '1px solid var(--gc-border)',
background: 'rgba(255,255,255,0.05)',
marginBottom: 12,
}}
/>
<button type="button" className="btn-primary" style={{ width: '100%' }} onClick={handleAddTarget}></button>
</BottomSheet>
<BottomSheet open={sheetOpen} title={selectedMarket} onClose={() => setSheetOpen(false)} height="half">
<div className="chip-row" style={{ padding: '0 0 12px' }}>
<button type="button" className={`chip${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`chip${rightTab === 'history' ? ' active' : ''}`} onClick={() => setRightTab('history')}></button>
</div>
{rightTab === 'trade' ? (
<VirtualTradePanel
market={selectedMarket}
summary={summary}
onOrder={async (side, qty, price) => {
await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
await refreshSummary();
<BottomSheet open={addSheetOpen} title="종목 검색" onClose={() => setAddSheetOpen(false)} height="full">
<div style={{ height: 'min(70vh, 520px)', overflow: 'hidden' }}>
<MarketSearchPanel
variant="embedded"
currentMarket={targets[0]?.market ?? 'KRW-BTC'}
onSelect={m => {
addTarget(m);
setAddSheetOpen(false);
}}
onClose={() => setAddSheetOpen(false)}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
<div key={t.id} className="card" style={{ padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'}>{t.side}</span>
<span>{t.quantity} @ {t.price?.toLocaleString()}</span>
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>{t.createdAt ?? ''}</div>
</div>
))}
</div>
)}
</div>
</BottomSheet>
<style>{`
+141
View File
@@ -222,3 +222,144 @@ a {
font-size: 12px;
color: var(--gc-text-muted);
}
/* ── 모바일 목록 → 상세/매매 스택 ── */
.stack-screen {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.stack-screen-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0 16px 24px;
}
.stack-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-stack-header {
flex-wrap: nowrap;
}
.mobile-back-btn {
font-size: 22px;
min-width: 44px;
padding: 0 8px;
color: var(--gc-accent);
flex-shrink: 0;
}
.mobile-stack-header-titles {
flex: 1;
min-width: 0;
}
.mobile-stack-title {
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-stack-subtitle {
font-size: 12px;
margin-top: 2px;
}
.mobile-list-row {
padding: 12px 14px;
}
.mobile-list-row--unread {
background: rgba(139, 92, 246, 0.08);
}
.mobile-list-row-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
}
.mobile-list-row-info {
flex: 1;
min-width: 0;
}
.mobile-list-row-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
font-size: 15px;
}
.mobile-list-row-meta {
font-size: 11px;
margin-top: 4px;
line-height: 1.35;
}
.mobile-list-row-pin {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.mobile-list-row-actions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.mobile-action-btn {
padding: 10px 6px;
font-size: 12px;
font-weight: 600;
min-height: 40px;
text-align: center;
}
.mobile-action-btn--buy {
background: var(--gc-green);
color: #fff;
}
.icon-btn {
min-width: 36px;
min-height: 36px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
font-size: 14px;
}
.icon-btn--danger {
color: var(--gc-red);
}
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--gc-text-dim);
flex-shrink: 0;
}
.live-dot.live { background: var(--gc-green); }
.live-dot.connecting { background: var(--gc-orange); }
.live-dot.disconnected { background: var(--gc-red); }
.mobile-history-row,
.mobile-detail-row {
padding: 12px 14px;
}