Compare commits
10 Commits
07b43c48c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d78b2c430 | |||
| bdb055cb32 | |||
| d7e7ccdb16 | |||
| 8fc7c2cb70 | |||
| 7d01c72e58 | |||
| 9ab45b64b6 | |||
| 22897c3eb6 | |||
| 5f4742085c | |||
| 5dab3d99c1 | |||
| b559eeda58 |
9
package-lock.json
generated
9
package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -2921,6 +2922,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3",
|
||||||
|
"three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -26,18 +26,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suspend-loading {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeUp {
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(16px);
|
transform: translateY(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-main > * {
|
.site-main>* {
|
||||||
animation: fadeUp 0.6s ease both;
|
animation: fadeUp 0.6s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
import Loading from './components/Loading';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -8,7 +9,9 @@ function App() {
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="site-main">
|
<main className="site-main">
|
||||||
<Outlet />
|
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
|
||||||
|
<Outlet />
|
||||||
|
</React.Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
50
src/api.js
50
src/api.js
@@ -1,6 +1,30 @@
|
|||||||
// src/api.js
|
// src/api.js
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||||
|
|
||||||
|
const toApiUrl = (path) => {
|
||||||
|
if (!API_BASE) return path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = new URL(API_BASE, window.location.origin);
|
||||||
|
// Ensure base pathname ends with '/' if it's not the root or if likely intended as a directory
|
||||||
|
if (!base.pathname.endsWith('/')) {
|
||||||
|
base.pathname += '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading slash from path to avoid double slashes when joining
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
|
||||||
|
return new URL(cleanPath, base).toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Invalid VITE_API_BASE configuration:", error);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export async function apiGet(path) {
|
export async function apiGet(path) {
|
||||||
const res = await fetch(path, { headers: { "Accept": "application/json" } });
|
const res = await fetch(toApiUrl(path), {
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
@@ -9,7 +33,7 @@ export async function apiGet(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path) {
|
export async function apiDelete(path) {
|
||||||
const res = await fetch(path, { method: "DELETE" });
|
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||||
@@ -18,7 +42,7 @@ export async function apiDelete(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost(path, body) {
|
export async function apiPost(path, body) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(toApiUrl(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
@@ -58,14 +82,22 @@ export function deleteHistory(id) {
|
|||||||
return apiDelete(`/api/history/${id}`);
|
return apiDelete(`/api/history/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStockNews(limit = 20) {
|
export function getStockNews(limit = 20, category) {
|
||||||
return apiGet(`/api/stock/news?limit=${limit}`);
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (category) {
|
||||||
|
qs.set("category", category);
|
||||||
|
}
|
||||||
|
return apiGet(`/api/stock/news?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerStockScrap() {
|
export function getStockIndices() {
|
||||||
return apiPost("/api/admin/stock/scrap");
|
return apiGet("/api/stock/indices");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStockHealth() {
|
export function getTradeBalance() {
|
||||||
return apiGet("/api/stock/health");
|
return apiGet("/api/trade/balance");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTradeOrder(payload) {
|
||||||
|
return apiPost("/api/trade/order", payload);
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/components/Loading.css
Normal file
58
src/components/Loading.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner__circle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--accent, #f7a8a5);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner__text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted, #b6b1a9);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-skeleton__line {
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.05) 25%,
|
||||||
|
rgba(255, 255, 255, 0.1) 50%,
|
||||||
|
rgba(255, 255, 255, 0.05) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/components/Loading.jsx
Normal file
23
src/components/Loading.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Loading.css';
|
||||||
|
|
||||||
|
const Loading = ({ type = 'spinner', message = '로딩 중...' }) => {
|
||||||
|
if (type === 'skeleton') {
|
||||||
|
return (
|
||||||
|
<div className="loading-skeleton">
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '60%' }}></div>
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '80%' }}></div>
|
||||||
|
<div className="loading-skeleton__line" style={{ width: '40%' }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<div className="loading-spinner__circle"></div>
|
||||||
|
{message && <p className="loading-spinner__text">{message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
@@ -83,7 +83,8 @@ const inferDateFromSlug = (slug) => {
|
|||||||
|
|
||||||
export const getBlogPosts = () => {
|
export const getBlogPosts = () => {
|
||||||
const modules = import.meta.glob('/src/content/blog/**/*.md', {
|
const modules = import.meta.glob('/src/content/blog/**/*.md', {
|
||||||
as: 'raw',
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
eager: true,
|
eager: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
59
src/pages/effect-lab/EffectLab.css
Normal file
59
src/pages/effect-lab/EffectLab.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.effect-lab {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
/* Adjust based on navbar height */
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #050505;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab canvas {
|
||||||
|
display: block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 0 20px rgba(68, 170, 221, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-mode {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(68, 170, 221, 0.1);
|
||||||
|
border: 1px solid rgba(68, 170, 221, 0.3);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #44aadd;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-mode span {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-lab-overlay p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
218
src/pages/effect-lab/EffectLab.jsx
Normal file
218
src/pages/effect-lab/EffectLab.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import './EffectLab.css';
|
||||||
|
|
||||||
|
const EffectLab = () => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const requestRef = useRef();
|
||||||
|
const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
const COUNT = 1500;
|
||||||
|
const SWORD_COLOR = 0x44aadd;
|
||||||
|
const SWORD_EMISSIVE = 0x112244;
|
||||||
|
|
||||||
|
// --- Helper: Random Range ---
|
||||||
|
const rand = (min, max) => Math.random() * (max - min) + min;
|
||||||
|
|
||||||
|
// --- Setup Scene ---
|
||||||
|
const width = containerRef.current.clientWidth;
|
||||||
|
const height = containerRef.current.clientHeight;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x050505, 0.002);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
|
camera.position.z = 80;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
// Tone mapping for better glow look
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// --- Lighting ---
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100);
|
||||||
|
scene.add(pointLight);
|
||||||
|
|
||||||
|
// --- Geometry & Material ---
|
||||||
|
// Sword shape: Cone stretched
|
||||||
|
const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8);
|
||||||
|
geometry.rotateX(Math.PI / 2); // Point towards Z
|
||||||
|
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: SWORD_COLOR,
|
||||||
|
emissive: SWORD_EMISSIVE,
|
||||||
|
shininess: 100,
|
||||||
|
flatShading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
// --- Particle Data ---
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const particles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
particles.push({
|
||||||
|
pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)),
|
||||||
|
vel: new THREE.Vector3(),
|
||||||
|
acc: new THREE.Vector3(),
|
||||||
|
// Orbit parameters
|
||||||
|
angle: rand(0, Math.PI * 2),
|
||||||
|
radius: rand(15, 30),
|
||||||
|
speed: rand(0.02, 0.05),
|
||||||
|
// Offset for natural movement
|
||||||
|
offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mouse & Interaction State ---
|
||||||
|
const mouse = new THREE.Vector3();
|
||||||
|
const target = new THREE.Vector3();
|
||||||
|
const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
|
let isMouseDown = false;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||||
|
raycaster.ray.intersectPlane(mousePlane, mouse);
|
||||||
|
|
||||||
|
// Allow light to follow mouse
|
||||||
|
pointLight.position.copy(mouse);
|
||||||
|
pointLight.position.z = 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); };
|
||||||
|
const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); };
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mousedown', handleMouseDown);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// --- Animation Loop ---
|
||||||
|
const animate = () => {
|
||||||
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
time += 0.01;
|
||||||
|
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const p = particles[i];
|
||||||
|
|
||||||
|
// --- Behavior Logic ---
|
||||||
|
if (isMouseDown) {
|
||||||
|
// 1. ORBIT MODE: Rotate around mouse
|
||||||
|
p.angle += p.speed + 0.02; // Spin faster
|
||||||
|
|
||||||
|
const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius;
|
||||||
|
const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius;
|
||||||
|
// Spiraling Z for depth
|
||||||
|
const orbitZ = Math.sin(p.angle * 2 + time) * 10;
|
||||||
|
|
||||||
|
target.set(orbitX, orbitY, orbitZ);
|
||||||
|
|
||||||
|
// Strong pull to orbit positions
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.08);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel
|
||||||
|
|
||||||
|
// Add noise/wandering
|
||||||
|
const noiseX = Math.sin(time + i * 0.1) * 5;
|
||||||
|
const noiseY = Math.cos(time + i * 0.1) * 5;
|
||||||
|
|
||||||
|
target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0));
|
||||||
|
|
||||||
|
// Gentle pull
|
||||||
|
p.acc.subVectors(target, p.pos).multiplyScalar(0.008);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physics update
|
||||||
|
p.vel.add(p.acc);
|
||||||
|
p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag
|
||||||
|
p.pos.add(p.vel);
|
||||||
|
|
||||||
|
// Update Matrix
|
||||||
|
dummy.position.copy(p.pos);
|
||||||
|
|
||||||
|
// Rotation: Look at velocity direction (dynamic) or mouse (focused)
|
||||||
|
// Blending lookAt target for smoother rotation
|
||||||
|
const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5));
|
||||||
|
|
||||||
|
// If moving very slowly, keep previous rotation to avoid jitter
|
||||||
|
if (p.vel.lengthSq() > 0.01) {
|
||||||
|
dummy.lookAt(lookPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale effect based on speed (stretch when fast)
|
||||||
|
const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5;
|
||||||
|
dummy.scale.set(1, 1, speedScale);
|
||||||
|
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// --- Resize ---
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const newWidth = containerRef.current.clientWidth;
|
||||||
|
const newHeight = containerRef.current.clientHeight;
|
||||||
|
|
||||||
|
camera.aspect = newWidth / newHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(newWidth, newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
if (containerRef.current && renderer.domElement) {
|
||||||
|
containerRef.current.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="effect-lab" ref={containerRef}>
|
||||||
|
<div className="effect-lab-overlay">
|
||||||
|
<h2>Sword Stream</h2>
|
||||||
|
<div className="active-mode">
|
||||||
|
MODE: <span>{mode}</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>Move</strong> to Guide |
|
||||||
|
<strong>Click & Hold</strong> to Orbit & Charge
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EffectLab;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
.stock {
|
.stock {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
|
/* Prevent overflow on small screens */
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-header {
|
.stock-header {
|
||||||
@@ -45,6 +47,15 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-ideas {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-card__title {
|
.stock-card__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -57,7 +68,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-status > div {
|
.stock-status>div {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -105,9 +116,18 @@
|
|||||||
background: rgba(249, 182, 177, 0.1);
|
background: rgba(249, 182, 177, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-success {
|
||||||
|
margin: 0;
|
||||||
|
color: #b5f0dd;
|
||||||
|
border: 1px solid rgba(106, 220, 187, 0.4);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(106, 220, 187, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.stock-grid {
|
.stock-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +144,18 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-filter-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter-row .stock-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-panel__head {
|
.stock-panel__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -165,6 +197,7 @@
|
|||||||
.stock-snapshot {
|
.stock-snapshot {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-snapshot__card {
|
.stock-snapshot__card {
|
||||||
@@ -174,6 +207,13 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
min-height: 94px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__card.is-highlight {
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-snapshot__card p {
|
.stock-snapshot__card p {
|
||||||
@@ -193,6 +233,19 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change.is-up {
|
||||||
|
color: #f3a7a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-snapshot__change.is-down {
|
||||||
|
color: #9fc5ff;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-schedule {
|
.stock-schedule {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -234,6 +287,28 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-tab {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-tab.is-active {
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.stock-news__item {
|
.stock-news__item {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -273,6 +348,189 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-trade {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__card strong {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 0.6fr));
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-up {
|
||||||
|
color: #f3a7a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-down {
|
||||||
|
color: #9fc5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-flat {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-result pre {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__card {
|
||||||
|
position: relative;
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal__head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-modal pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-order input,
|
||||||
|
.stock-order select {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.stock-header {
|
.stock-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -280,7 +538,89 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.stock {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-panel {
|
.stock-panel {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-filter-row {
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-actions .button {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status>div {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-status strong {
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-balance__summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__item {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the last item span full width if it's odd */
|
||||||
|
.stock-holdings__metric>*:last-child:nth-child(odd) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 15px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stock-holdings__metric {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-holdings__metric strong {
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { Link } from 'react-router-dom';
|
||||||
getStockHealth,
|
import { getStockIndices, getStockNews } from '../../api';
|
||||||
getStockNews,
|
import Loading from '../../components/Loading';
|
||||||
triggerStockScrap,
|
|
||||||
} from '../../api';
|
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
|
|
||||||
const formatDate = (value) => value ?? '-';
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('sv-SE');
|
||||||
|
};
|
||||||
|
|
||||||
const toDateValue = (value) => {
|
const toDateValue = (value) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const normalized = value.replace(' ', 'T').replace(/\./g, '-');
|
const date = new Date(value);
|
||||||
const date = new Date(normalized);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,67 +26,110 @@ const getLatestBy = (items, key) => {
|
|||||||
return filtered[0]?.[key] ?? null;
|
return filtered[0]?.[key] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeIndices = (data) => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => ({
|
||||||
|
name: item?.name ?? item?.key ?? '-',
|
||||||
|
value: item?.value ?? '-',
|
||||||
|
change: item?.change_value ?? item?.change ?? '',
|
||||||
|
percent: item?.change_percent ?? item?.percent ?? '',
|
||||||
|
direction: item?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data?.indices)) {
|
||||||
|
return data.indices.map((item) => ({
|
||||||
|
name: item?.name ?? item?.key ?? '-',
|
||||||
|
value: item?.value ?? '-',
|
||||||
|
change: item?.change_value ?? item?.change ?? '',
|
||||||
|
percent: item?.change_percent ?? item?.percent ?? '',
|
||||||
|
direction: item?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
return Object.entries(data)
|
||||||
|
.filter(([, value]) => value && typeof value === 'object')
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: value?.value ?? '-',
|
||||||
|
change: value?.change ?? '',
|
||||||
|
percent: value?.percent ?? '',
|
||||||
|
direction: value?.direction ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirection = (change, percent, direction) => {
|
||||||
|
if (direction === 'red') return 'up';
|
||||||
|
if (direction === 'blue') return 'down';
|
||||||
|
const pick = (value) =>
|
||||||
|
value === undefined || value === null || value === '' ? null : value;
|
||||||
|
const raw = pick(change) ?? pick(percent);
|
||||||
|
if (!raw) return '';
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (str.startsWith('-')) return 'down';
|
||||||
|
if (str.startsWith('+')) return 'up';
|
||||||
|
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
if (numeric > 0) return 'up';
|
||||||
|
if (numeric < 0) return 'down';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const Stock = () => {
|
const Stock = () => {
|
||||||
const [news, setNews] = useState([]);
|
const [newsDomestic, setNewsDomestic] = useState([]);
|
||||||
|
const [newsOverseas, setNewsOverseas] = useState([]);
|
||||||
|
const [newsCategory, setNewsCategory] = useState('domestic');
|
||||||
const [limit, setLimit] = useState(20);
|
const [limit, setLimit] = useState(20);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [scraping, setScraping] = useState(false);
|
const [newsError, setNewsError] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [indicesError, setIndicesError] = useState('');
|
||||||
const [health, setHealth] = useState({
|
const [indices, setIndices] = useState([]);
|
||||||
status: 'unknown',
|
const [indicesLoading, setIndicesLoading] = useState(false);
|
||||||
message: '',
|
const [autoRefreshMs] = useState(180000);
|
||||||
});
|
|
||||||
|
|
||||||
const latestCrawled = useMemo(
|
const combinedNews = useMemo(
|
||||||
() => getLatestBy(news, 'crawled_at'),
|
() => [...newsDomestic, ...newsOverseas],
|
||||||
[news]
|
[newsDomestic, newsOverseas]
|
||||||
);
|
);
|
||||||
const latestPublished = useMemo(
|
const latestPublished = useMemo(
|
||||||
() => getLatestBy(news, 'pub_date'),
|
() => getLatestBy(combinedNews, 'published_at'),
|
||||||
[news]
|
[combinedNews]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadNews = async () => {
|
const loadNews = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setNewsError('');
|
||||||
try {
|
try {
|
||||||
const data = await getStockNews(limit);
|
const [domestic, overseas] = await Promise.all([
|
||||||
setNews(Array.isArray(data) ? data : []);
|
getStockNews(limit, 'domestic'),
|
||||||
|
getStockNews(limit, 'overseas'),
|
||||||
|
]);
|
||||||
|
setNewsDomestic(Array.isArray(domestic) ? domestic : []);
|
||||||
|
setNewsOverseas(Array.isArray(overseas) ? overseas : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err?.message ?? String(err));
|
setNewsError(err?.message ?? String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadHealth = async () => {
|
const loadIndices = async () => {
|
||||||
|
setIndicesLoading(true);
|
||||||
|
setIndicesError('');
|
||||||
try {
|
try {
|
||||||
const data = await getStockHealth();
|
const data = await getStockIndices();
|
||||||
setHealth({
|
setIndices(normalizeIndices(data));
|
||||||
status: data?.ok ? 'ok' : 'warn',
|
|
||||||
message: data?.message ?? '',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setHealth({
|
setIndicesError(err?.message ?? String(err));
|
||||||
status: 'unknown',
|
|
||||||
message: err?.message ?? '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onScrap = async () => {
|
|
||||||
setScraping(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const result = await triggerStockScrap();
|
|
||||||
if (!result?.ok) {
|
|
||||||
throw new Error('스크랩 요청이 실패했습니다.');
|
|
||||||
}
|
|
||||||
await loadNews();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err?.message ?? String(err));
|
|
||||||
} finally {
|
} finally {
|
||||||
setScraping(false);
|
setIndicesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,18 +138,40 @@ const Stock = () => {
|
|||||||
}, [limit]);
|
}, [limit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHealth();
|
loadIndices();
|
||||||
}, []);
|
const timer = window.setInterval(loadIndices, autoRefreshMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [autoRefreshMs]);
|
||||||
|
|
||||||
|
const indexOrder = [
|
||||||
|
'KOSPI',
|
||||||
|
'KOSDAQ',
|
||||||
|
'KOSPI200',
|
||||||
|
'다우산업',
|
||||||
|
'나스닥',
|
||||||
|
'S&P500',
|
||||||
|
'원달러 환율',
|
||||||
|
];
|
||||||
|
const sortedIndices = [...indices].sort((a, b) => {
|
||||||
|
const aIndex = indexOrder.indexOf(a.name);
|
||||||
|
const bIndex = indexOrder.indexOf(b.name);
|
||||||
|
if (aIndex !== -1 || bIndex !== -1) {
|
||||||
|
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
const highlighted = new Set(['KOSPI', 'KOSDAQ', '원달러 환율']);
|
||||||
|
const activeNews =
|
||||||
|
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stock">
|
<div className="stock">
|
||||||
<header className="stock-header">
|
<header className="stock-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-kicker">Market Lab</p>
|
<p className="stock-kicker">마켓 랩</p>
|
||||||
<h1>Stock Lab</h1>
|
<h1>주식 랩</h1>
|
||||||
<p className="stock-sub">
|
<p className="stock-sub">
|
||||||
매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전
|
최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
|
||||||
빠르게 흐름을 파악할 수 있게 구성했습니다.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="stock-actions">
|
<div className="stock-actions">
|
||||||
<button
|
<button
|
||||||
@@ -114,100 +181,95 @@ const Stock = () => {
|
|||||||
>
|
>
|
||||||
뉴스 새로고침
|
뉴스 새로고침
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Link className="button ghost" to="/stock/trade">
|
||||||
className="button ghost"
|
거래 데스크
|
||||||
onClick={onScrap}
|
</Link>
|
||||||
disabled={scraping}
|
|
||||||
>
|
|
||||||
{scraping ? '스크랩 중...' : '스크랩 즉시 실행'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-card">
|
<div className="stock-card">
|
||||||
<p className="stock-card__title">오늘의 상태</p>
|
<p className="stock-card__title">다음 업데이트 아이디어</p>
|
||||||
<div className="stock-status">
|
<ul className="stock-ideas">
|
||||||
<div>
|
<li>관심 종목 실적 캘린더/일정 보기</li>
|
||||||
<span>Health</span>
|
<li>뉴스 감성 요약 및 키워드 트렌드</li>
|
||||||
<span className={`stock-pill is-${health.status}`}>
|
<li>보유 종목 알림(수익률/목표가)</li>
|
||||||
{health.status}
|
</ul>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>최근 스크랩</span>
|
|
||||||
<strong>{formatDate(latestCrawled)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>최근 발행</span>
|
|
||||||
<strong>{formatDate(latestPublished)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>기사 수</span>
|
|
||||||
<strong>{news.length}건</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{health.message ? (
|
|
||||||
<p className="stock-status__note">{health.message}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? <p className="stock-error">{error}</p> : null}
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
<section className="stock-grid">
|
<div>
|
||||||
<div className="stock-panel">
|
<p className="stock-panel__eyebrow">스냅샷</p>
|
||||||
<div className="stock-panel__head">
|
<h3>주요 지수</h3>
|
||||||
<div>
|
<p className="stock-panel__sub">
|
||||||
<p className="stock-panel__eyebrow">Snapshot</p>
|
주요 지수 값과 등락을 함께 확인합니다.
|
||||||
<h3>시장 스냅샷</h3>
|
</p>
|
||||||
<p className="stock-panel__sub">
|
|
||||||
지수/가격 API 연동을 위한 준비 구간입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-snapshot">
|
<div className="stock-panel__actions">
|
||||||
{['KOSPI', 'KOSDAQ', 'NASDAQ'].map((label) => (
|
{indicesLoading ? (
|
||||||
<div key={label} className="stock-snapshot__card">
|
<Loading type="spinner" message="" />
|
||||||
<p>{label}</p>
|
) : null}
|
||||||
<strong>--</strong>
|
<button
|
||||||
<span>연동 예정</span>
|
className="button ghost small"
|
||||||
</div>
|
onClick={loadIndices}
|
||||||
))}
|
disabled={indicesLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stock-snapshot">
|
||||||
<div className="stock-panel">
|
{indicesError ? (
|
||||||
<div className="stock-panel__head">
|
<p className="stock-empty">{indicesError}</p>
|
||||||
<div>
|
) : sortedIndices.length === 0 ? (
|
||||||
<p className="stock-panel__eyebrow">Schedule</p>
|
<p className="stock-empty">
|
||||||
<h3>스크랩 일정</h3>
|
지수 데이터가 없습니다.
|
||||||
<p className="stock-panel__sub">
|
</p>
|
||||||
매일 오전 8시에 자동 스크랩이 실행됩니다.
|
) : (
|
||||||
</p>
|
sortedIndices.map((item) => {
|
||||||
</div>
|
const direction = getDirection(
|
||||||
</div>
|
item.change,
|
||||||
<div className="stock-schedule">
|
item.percent,
|
||||||
<div>
|
item.direction
|
||||||
<span>자동 실행</span>
|
);
|
||||||
<strong>08:00 KST</strong>
|
const changeText = [item.change, item.percent]
|
||||||
</div>
|
.filter(Boolean)
|
||||||
<div>
|
.join(' ');
|
||||||
<span>수동 실행</span>
|
return (
|
||||||
<strong>관리자 전용</strong>
|
<div
|
||||||
</div>
|
key={item.name}
|
||||||
<div>
|
className={`stock-snapshot__card ${highlighted.has(item.name)
|
||||||
<span>최근 스크랩</span>
|
? 'is-highlight'
|
||||||
<strong>{formatDate(latestCrawled)}</strong>
|
: ''
|
||||||
</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<p>{item.name}</p>
|
||||||
|
<strong>{item.value ?? '--'}</strong>
|
||||||
|
<span
|
||||||
|
className={`stock-snapshot__change ${direction === 'up'
|
||||||
|
? 'is-up'
|
||||||
|
: direction === 'down'
|
||||||
|
? 'is-down'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{changeText || '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="stock-panel">
|
<section className="stock-filter-row">
|
||||||
|
<div className="stock-panel stock-panel--compact">
|
||||||
<div className="stock-panel__head">
|
<div className="stock-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-panel__eyebrow">Filter</p>
|
<p className="stock-panel__eyebrow">필터</p>
|
||||||
<h3>뉴스 필터</h3>
|
<h3>뉴스 필터</h3>
|
||||||
<p className="stock-panel__sub">
|
<p className="stock-panel__sub">
|
||||||
표시 개수를 조정해 빠르게 훑어볼 수 있습니다.
|
표시할 뉴스 개수를 조정합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,65 +284,129 @@ const Stock = () => {
|
|||||||
>
|
>
|
||||||
{[10, 20, 30, 40].map((value) => (
|
{[10, 20, 30, 40].map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{value}개
|
{value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p className="stock-filter__note">
|
<p className="stock-filter__note">
|
||||||
최신 기사부터 정렬됩니다.
|
최신 뉴스가 먼저 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stock-panel stock-panel--compact">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">요약</p>
|
||||||
|
<h3>뉴스 요약</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
최신 발행 시각과 기사 수를 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-status">
|
||||||
|
<div>
|
||||||
|
<span>최신 발행</span>
|
||||||
|
<strong>{formatDate(latestPublished)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>국내</span>
|
||||||
|
<strong>{newsDomestic.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>해외</span>
|
||||||
|
<strong>{newsOverseas.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="stock-panel stock-panel--wide">
|
<section className="stock-panel stock-panel--wide">
|
||||||
<div className="stock-panel__head">
|
<div className="stock-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="stock-panel__eyebrow">Headlines</p>
|
<p className="stock-panel__eyebrow">헤드라인</p>
|
||||||
<h3>트렌드 기사</h3>
|
<h3>시장 뉴스</h3>
|
||||||
<p className="stock-panel__sub">
|
<p className="stock-panel__sub">
|
||||||
스크랩된 뉴스 요약을 바로 확인할 수 있습니다.
|
Stock Lab API에서 최신 뉴스를 불러옵니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stock-panel__actions">
|
<div className="stock-panel__actions">
|
||||||
{loading ? (
|
{loading && <span className="stock-chip">Updating...</span>}
|
||||||
<span className="stock-chip">불러오는 중</span>
|
<span className="stock-chip">
|
||||||
) : null}
|
국내 {newsDomestic.length} / 해외{' '}
|
||||||
<span className="stock-chip">{news.length}건</span>
|
{newsOverseas.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading && combinedNews.length === 0 ? (
|
||||||
<p className="stock-empty">뉴스를 불러오는 중...</p>
|
<Loading type="skeleton" />
|
||||||
) : news.length === 0 ? (
|
) : newsError ? (
|
||||||
<p className="stock-empty">표시할 뉴스가 없습니다.</p>
|
<p className="stock-empty">{newsError}</p>
|
||||||
|
) : combinedNews.length === 0 ? (
|
||||||
|
<p className="stock-empty">뉴스가 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="stock-news">
|
<>
|
||||||
{news.map((item) => (
|
<div className="stock-tabs">
|
||||||
<article key={item.id} className="stock-news__item">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<p className="stock-news__title">
|
className={`stock-tab ${newsCategory === 'domestic'
|
||||||
{item.title}
|
? 'is-active'
|
||||||
</p>
|
: ''
|
||||||
<p className="stock-news__summary">
|
}`}
|
||||||
{item.summary}
|
onClick={() => setNewsCategory('domestic')}
|
||||||
</p>
|
>
|
||||||
</div>
|
국내
|
||||||
<div className="stock-news__meta">
|
</button>
|
||||||
<span>{item.press}</span>
|
<button
|
||||||
<span>{item.pub_date}</span>
|
type="button"
|
||||||
<a
|
className={`stock-tab ${newsCategory === 'overseas'
|
||||||
href={item.link}
|
? 'is-active'
|
||||||
target="_blank"
|
: ''
|
||||||
rel="noreferrer"
|
}`}
|
||||||
|
onClick={() => setNewsCategory('overseas')}
|
||||||
|
>
|
||||||
|
해외
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeNews.length === 0 ? (
|
||||||
|
<p className="stock-empty">
|
||||||
|
해당 카테고리 뉴스가 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="stock-news">
|
||||||
|
{activeNews.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.id ?? item.link}
|
||||||
|
className="stock-news__item"
|
||||||
>
|
>
|
||||||
원문 보기
|
<div>
|
||||||
</a>
|
<p className="stock-news__title">
|
||||||
</div>
|
{item.title}
|
||||||
</article>
|
</p>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="stock-news__meta">
|
||||||
|
<span>
|
||||||
|
{formatDate(item.published_at)}
|
||||||
|
</span>
|
||||||
|
{item.sentiment ? (
|
||||||
|
<span className="stock-chip">
|
||||||
|
{item.sentiment}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
원문 보기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
424
src/pages/stock/StockTrade.jsx
Normal file
424
src/pages/stock/StockTrade.jsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { createTradeOrder, getTradeBalance } from '../../api';
|
||||||
|
import './Stock.css';
|
||||||
|
|
||||||
|
const formatNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(numeric);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
if (typeof value === 'string' && value.includes('%')) return value;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return `${numeric.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFirst = (...values) =>
|
||||||
|
values.find((value) => value !== undefined && value !== null && value !== '');
|
||||||
|
|
||||||
|
const getQty = (item) =>
|
||||||
|
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||||||
|
|
||||||
|
const getBuyPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.buy_price,
|
||||||
|
item?.avg_price,
|
||||||
|
item?.avg,
|
||||||
|
item?.purchase_price,
|
||||||
|
item?.buyPrice,
|
||||||
|
item?.price
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCurrentPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.current_price,
|
||||||
|
item?.current,
|
||||||
|
item?.cur_price,
|
||||||
|
item?.now_price,
|
||||||
|
item?.market_price
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProfitRate = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.profit_rate,
|
||||||
|
item?.profitRate,
|
||||||
|
item?.profit_pct,
|
||||||
|
item?.profitPercent,
|
||||||
|
item?.pnl_rate,
|
||||||
|
item?.return_rate,
|
||||||
|
item?.yield
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProfitLoss = (item) =>
|
||||||
|
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||||||
|
|
||||||
|
const toNumeric = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
||||||
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StockTrade = () => {
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [manualForm, setManualForm] = useState({
|
||||||
|
code: '',
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
type: 'buy',
|
||||||
|
});
|
||||||
|
const [manualLoading, setManualLoading] = useState(false);
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [manualResult, setManualResult] = useState(null);
|
||||||
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
|
const loadBalance = async () => {
|
||||||
|
setBalanceLoading(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const data = await getTradeBalance();
|
||||||
|
setBalance(data);
|
||||||
|
} catch (err) {
|
||||||
|
setBalanceError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setBalanceLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitManualOrder = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setManualLoading(true);
|
||||||
|
setManualError('');
|
||||||
|
setManualResult(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
ticker: manualForm.code.trim(),
|
||||||
|
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||||||
|
quantity: Number(manualForm.qty),
|
||||||
|
price: Number(manualForm.price),
|
||||||
|
};
|
||||||
|
const result = await createTradeOrder(payload);
|
||||||
|
setManualResult(result ?? { ok: true });
|
||||||
|
if (result?.kis_result !== undefined) {
|
||||||
|
const message =
|
||||||
|
typeof result.kis_result === 'string'
|
||||||
|
? result.kis_result
|
||||||
|
: JSON.stringify(result.kis_result, null, 2);
|
||||||
|
setKisModal(message);
|
||||||
|
}
|
||||||
|
await loadBalance();
|
||||||
|
} catch (err) {
|
||||||
|
setManualError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setManualLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBalance();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const holdings = useMemo(() => {
|
||||||
|
if (!balance) return [];
|
||||||
|
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||||||
|
if (Array.isArray(balance.positions)) return balance.positions;
|
||||||
|
if (Array.isArray(balance.items)) return balance.items;
|
||||||
|
return [];
|
||||||
|
}, [balance]);
|
||||||
|
const summary = balance?.summary ?? {};
|
||||||
|
const totalEval =
|
||||||
|
summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||||||
|
const deposit =
|
||||||
|
summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stock">
|
||||||
|
<header className="stock-header">
|
||||||
|
<div>
|
||||||
|
<p className="stock-kicker">거래 데스크</p>
|
||||||
|
<h1>주식 거래</h1>
|
||||||
|
<p className="stock-sub">
|
||||||
|
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
|
||||||
|
</p>
|
||||||
|
<div className="stock-actions">
|
||||||
|
<Link className="button ghost" to="/stock">
|
||||||
|
주식 랩으로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-card">
|
||||||
|
<p className="stock-card__title">계좌 요약</p>
|
||||||
|
<div className="stock-status">
|
||||||
|
<div>
|
||||||
|
<span>총 평가금액</span>
|
||||||
|
<strong>{formatNumber(totalEval)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>예수금</span>
|
||||||
|
<strong>{formatNumber(deposit)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>보유 종목</span>
|
||||||
|
<strong>{holdings.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary.note ? (
|
||||||
|
<p className="stock-status__note">{summary.note}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">잔고</p>
|
||||||
|
<h3>보유 현황</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stock-panel__actions">
|
||||||
|
{balanceLoading ? (
|
||||||
|
<span className="stock-chip">조회 중</span>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={loadBalance}
|
||||||
|
disabled={balanceLoading}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stock-balance">
|
||||||
|
<div className="stock-balance__summary">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: '총 평가',
|
||||||
|
value: totalEval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '예수금',
|
||||||
|
value: deposit,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="stock-balance__card"
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{formatNumber(item.value)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{holdings.length ? (
|
||||||
|
<div className="stock-holdings">
|
||||||
|
{holdings.map((item, idx) => {
|
||||||
|
const profitLoss = getProfitLoss(item);
|
||||||
|
const profitLossNumeric = toNumeric(profitLoss);
|
||||||
|
const profitClass =
|
||||||
|
profitLossNumeric > 0
|
||||||
|
? 'is-up'
|
||||||
|
: profitLossNumeric < 0
|
||||||
|
? 'is-down'
|
||||||
|
: profitLossNumeric === 0
|
||||||
|
? 'is-flat'
|
||||||
|
: '';
|
||||||
|
const profitRate = getProfitRate(item);
|
||||||
|
const profitRateNumeric = toNumeric(profitRate);
|
||||||
|
const profitRateClass =
|
||||||
|
profitRateNumeric > 0
|
||||||
|
? 'is-up'
|
||||||
|
: profitRateNumeric < 0
|
||||||
|
? 'is-down'
|
||||||
|
: profitRateNumeric === 0
|
||||||
|
? 'is-flat'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code ?? `${item.name}-${idx}`}
|
||||||
|
className="stock-holdings__item"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="stock-holdings__name">
|
||||||
|
{item.name ?? item.code ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<span className="stock-holdings__code">
|
||||||
|
{item.code ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수량</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(getQty(item))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>매입가</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(getBuyPrice(item))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>현재가</span>
|
||||||
|
<strong>
|
||||||
|
{formatNumber(
|
||||||
|
getCurrentPrice(item)
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>수익률</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitRateClass}`}
|
||||||
|
>
|
||||||
|
{formatPercent(profitRate)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stock-holdings__metric">
|
||||||
|
<span>평가손익</span>
|
||||||
|
<strong
|
||||||
|
className={`stock-profit ${profitClass}`}
|
||||||
|
>
|
||||||
|
{formatNumber(profitLoss)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="stock-empty">보유 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stock-panel stock-panel--wide">
|
||||||
|
<div className="stock-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="stock-panel__eyebrow">수동 주문</p>
|
||||||
|
<h3>직접 매수/매도</h3>
|
||||||
|
<p className="stock-panel__sub">
|
||||||
|
종목명 또는 종목코드를 입력하고 매수/매도 주문을
|
||||||
|
요청합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stock-order" onSubmit={submitManualOrder}>
|
||||||
|
<label>
|
||||||
|
종목명/코드
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
code: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="005930 또는 삼성전자"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
매수/매도
|
||||||
|
<select
|
||||||
|
value={manualForm.type}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="buy">매수</option>
|
||||||
|
<option value="sell">매도</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
수량
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={manualForm.qty}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
qty: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
금액(원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={manualForm.price}
|
||||||
|
onChange={(event) =>
|
||||||
|
setManualForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={manualLoading}
|
||||||
|
>
|
||||||
|
{manualLoading ? '요청 중...' : '주문 요청'}
|
||||||
|
</button>
|
||||||
|
{manualError ? (
|
||||||
|
<p className="stock-error">{manualError}</p>
|
||||||
|
) : null}
|
||||||
|
{manualResult ? (
|
||||||
|
<div className="stock-result">
|
||||||
|
<p className="stock-result__title">요청 결과</p>
|
||||||
|
<pre>
|
||||||
|
{typeof manualResult === 'string'
|
||||||
|
? manualResult
|
||||||
|
: JSON.stringify(manualResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{kisModal ? (
|
||||||
|
<div className="stock-modal" role="dialog" aria-modal="true">
|
||||||
|
<div
|
||||||
|
className="stock-modal__backdrop"
|
||||||
|
onClick={() => setKisModal('')}
|
||||||
|
/>
|
||||||
|
<div className="stock-modal__card">
|
||||||
|
<div className="stock-modal__head">
|
||||||
|
<h4>주문 결과</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => setKisModal('')}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre>{kisModal}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockTrade;
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { lazy } from 'react';
|
||||||
import Home from './pages/home/Home';
|
|
||||||
import Blog from './pages/blog/Blog';
|
const Home = lazy(() => import('./pages/home/Home'));
|
||||||
import Lotto from './pages/lotto/Lotto';
|
const Blog = lazy(() => import('./pages/blog/Blog'));
|
||||||
import Travel from './pages/travel/Travel';
|
const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
||||||
import Stock from './pages/stock/Stock';
|
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||||
|
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||||
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -36,6 +39,12 @@ export const navLinks = [
|
|||||||
path: '/travel',
|
path: '/travel',
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'lab',
|
||||||
|
label: 'Lab',
|
||||||
|
path: '/lab',
|
||||||
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appRoutes = [
|
export const appRoutes = [
|
||||||
@@ -55,8 +64,16 @@ export const appRoutes = [
|
|||||||
path: 'stock',
|
path: 'stock',
|
||||||
element: <Stock />,
|
element: <Stock />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'stock/trade',
|
||||||
|
element: <StockTrade />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lab',
|
||||||
|
element: <EffectLab />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user