Unify popup UI to TradeAlertModal design system.

Add AppPopup shell, shared CSS, MUI theme overrides, and center positioning for consistent modal styling across frontend and frontend_golden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-05-23 16:56:37 +09:00
parent 67db5982d9
commit eaf067db1c
15 changed files with 1383 additions and 494 deletions
+221 -141
View File
@@ -5,7 +5,9 @@
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
*/
import React, { useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { Theme } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import {
saveLiveStrategySettings,
type LiveStrategySettingsDto,
@@ -28,6 +30,20 @@ interface LiveStrategyPanelProps {
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
}
const ACCENT = '#3f7ef5';
const ACCENT_GLOW = 'rgba(63,126,245,0.22)';
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
</svg>
);
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
theme, market, strategies, settings,
watchlistCount = 0, monitoredMarkets = [],
@@ -35,6 +51,18 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
}) => {
const [saving, setSaving] = useState(false);
const {
panelRef,
dragging,
onHeaderPointerDown,
headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({
centerOnMount: true,
initialPosition: { x: 40, y: 40 },
});
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
const prev = settings;
@@ -61,157 +89,209 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const execType = settings.executionType;
const stratId = settings.strategyId;
const monCount = monitoredMarkets.length;
const selectedStrategy = strategies.find(s => s.id === stratId);
return (
<div className={`lsp-wrap lsp-wrap--${theme}`}>
<div className="lsp-header">
<svg className="lsp-icon" width="13" height="13" viewBox="0 0 14 14"
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="5.5"/>
<polyline points="5,7 7,9 10,5"/>
</svg>
<span className="lsp-title"> </span>
{saving && <span className="lsp-saving"> </span>}
{onClose && (
<button className="lsp-close-btn" onClick={onClose} title="닫기">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<line x1="1" y1="1" x2="10" y2="10"/>
<line x1="10" y1="1" x2="1" y2="10"/>
</svg>
</button>
)}
</div>
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
? '보유 자산 기준'
: '순수 지표 기준';
<div className="lsp-monitor-stats">
<span> {watchlistCount}</span>
<span className="lsp-monitor-sep">·</span>
<span className={monCount > 0 ? 'lsp-monitor-on' : ''}>
{monCount}
</span>
</div>
{isOn && stratId && watchlistCount > 0 && (
<p className="lsp-monitor-hint">
.
</p>
)}
{monCount > 0 && (
<div className="lsp-monitor-list" title={monitoredMarkets.join(', ')}>
{monitoredMarkets.slice(0, 8).map(m => (
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
))}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
</div>
)}
{isOn && !stratId && (
<p className="lsp-monitor-hint"> {watchlistCount} .</p>
)}
<div className="lsp-row">
<span className="lsp-label"> </span>
<label className="lsp-toggle">
<input
type="checkbox"
checked={isOn}
onChange={e => persist({ isLiveCheck: e.target.checked })}
/>
<span className="lsp-toggle-slider" />
</label>
</div>
<div className={`lsp-row${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
value={stratId ?? ''}
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
const panel = (
<div className={`lsp-overlay lsp-overlay--${theme}`}>
<div
ref={panelRef}
className="lsp-modal app-popup-shell"
style={{
...panelStyle,
zIndex: 10000,
cursor: dragging ? 'grabbing' : undefined,
'--lsp-accent': ACCENT,
'--lsp-accent-glow': ACCENT_GLOW,
} as React.CSSProperties}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header lsp-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="lsp-header-left">
<span className="lsp-badge" style={{ background: ACCENT }}>LIVE</span>
<span className="lsp-header-title">
STRATEGY CHECK
<span className="lsp-header-ko"> ( )</span>
</span>
</div>
{saving && <span className="lsp-saving"> </span>}
{onClose && (
<button type="button" className="lsp-close" onClick={onClose} title="닫기"></button>
)}
</div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<div className="lsp-radio-group">
<label className="lsp-radio">
<input
type="radio"
name="execType"
value="CANDLE_CLOSE"
checked={execType === 'CANDLE_CLOSE'}
disabled={!isOn}
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> 1 </span>
<div className="lsp-summary-section">
<div className="lsp-summary-row">
<span className="lsp-summary-label"> </span>
<span className="lsp-summary-value">{watchlistCount}</span>
</div>
<div className="lsp-summary-row">
<span className="lsp-summary-label"> </span>
<span className={`lsp-summary-value${monCount > 0 ? ' lsp-summary-value--active' : ''}`}>
{monCount}
</span>
</label>
<label className="lsp-radio">
<input
type="radio"
name="execType"
value="REALTIME_TICK"
checked={execType === 'REALTIME_TICK'}
</div>
{isOn && stratId && watchlistCount > 0 && (
<p className="lsp-summary-hint">
.
</p>
)}
</div>
{monCount > 0 && (
<div className="lsp-info-card">
<div className="lsp-info-icon">
<CandleIcon color={ACCENT} />
</div>
<div className="lsp-info-body">
<div className="lsp-info-row">
<span className="lsp-info-label"></span>
<span className="lsp-info-val lsp-monitor-chips">
{monitoredMarkets.slice(0, 8).map(m => (
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
))}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
</span>
</div>
{isOn && selectedStrategy && (
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{selectedStrategy.name}</span>
</div>
)}
</div>
</div>
)}
<div className="lsp-body">
<div className="lsp-field-row">
<span className="lsp-field-label"> </span>
<label className="lsp-toggle">
<input
type="checkbox"
checked={isOn}
onChange={e => persist({ isLiveCheck: e.target.checked })}
/>
<span className="lsp-toggle-slider" />
</label>
</div>
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
<span className="lsp-field-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> (3)</span>
<span className="lsp-radio-desc"> , 3 </span>
</span>
</label>
value={stratId ?? ''}
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
<div className="lsp-option-group">
<label className={`lsp-option${execType === 'CANDLE_CLOSE' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="execType"
value="CANDLE_CLOSE"
checked={execType === 'CANDLE_CLOSE'}
disabled={!isOn}
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> 1 </span>
</span>
</label>
<label className={`lsp-option${execType === 'REALTIME_TICK' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="execType"
value="REALTIME_TICK"
checked={execType === 'REALTIME_TICK'}
disabled={!isOn}
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> (3)</span>
<span className="lsp-option-desc"> , 3 </span>
</span>
</label>
</div>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
<div className="lsp-option-group">
<label className={`lsp-option${(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="lsp-posMode"
value="LONG_ONLY"
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> </span>
</span>
</label>
<label className={`lsp-option${settings.positionMode === 'SIGNAL_ONLY' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="lsp-posMode"
value="SIGNAL_ONLY"
checked={settings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> , </span>
</span>
</label>
</div>
</div>
{isOn && !stratId && (
<p className="lsp-hint"> {watchlistCount} .</p>
)}
{isOn && (
<div className="lsp-status-bar">
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
<span className="lsp-status-text">
{stratId
? `${selectedStrategy?.name ?? '전략'} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
: '전략을 선택하세요'}
</span>
</div>
)}
<p className="lsp-disclaimer">
ON () .
</p>
</div>
</div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<div className="lsp-radio-group">
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="LONG_ONLY"
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> </span>
</span>
</label>
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="SIGNAL_ONLY"
checked={settings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> , </span>
</span>
</label>
</div>
</div>
{isOn && (
<div className="lsp-status">
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
{stratId
? `${strategies.find(s => s.id === stratId)?.name ?? '전략'} · 관심 ${monCount}종목`
: '전략을 선택하세요'}
</div>
)}
</div>
);
if (typeof document === 'undefined') return null;
return createPortal(panel, document.body);
};
export default LiveStrategyPanel;