디자인 변경

This commit is contained in:
2026-02-11 15:27:03 +09:00
parent 95291e6922
commit f650d51f68
15 changed files with 667 additions and 505 deletions

View File

@@ -5,77 +5,77 @@ import type {
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
interface KisApiBaseResponse {
ok: boolean;
message: string;
}
async function postKisAuthApi<T extends KisApiBaseResponse>(
endpoint: string,
credentials: KisRuntimeCredentials,
fallbackErrorMessage: string,
): Promise<T> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as T;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || fallbackErrorMessage);
}
return payload;
}
/**
* KIS API 키 검증 요청
* @param credentials 검증할 키 정보
* @description KIS API 키 검증합니다.
* @see app/api/kis/validate/route.ts
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
const response = await fetch("/api/kis/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisValidateResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
}
return payload;
return postKisAuthApi<DashboardKisValidateResponse>(
"/api/kis/validate",
credentials,
"API 키 검증에 실패했습니다.",
);
}
/**
* KIS 접근토큰 폐기 요청
* @param credentials 폐기할 키 정보
* @description KIS 액세스 토큰 폐기합니다.
* @see app/api/kis/revoke/route.ts
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
const response = await fetch("/api/kis/revoke", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisRevokeResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
}
return payload;
return postKisAuthApi<DashboardKisRevokeResponse>(
"/api/kis/revoke",
credentials,
"API 토큰 폐기에 실패했습니다.",
);
}
/**
* KIS 실시간 웹소켓 승인키 발급 요청
* @param credentials 인증 정보
* @description 웹소켓 승인키와 WS URL을 조회합니다.
* @see app/api/kis/ws/approval/route.ts
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const response = await fetch("/api/kis/ws/approval", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
"/api/kis/ws/approval",
credentials,
"웹소켓 승인키 발급에 실패했습니다.",
);
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
throw new Error(
payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.",
);
if (!payload.approvalKey || !payload.wsUrl) {
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
}
return payload;

View File

@@ -2,23 +2,29 @@ import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/dashboard/apis/kis-auth.api";
import {
KeyRound,
Shield,
CheckCircle2,
XCircle,
Lock,
Sparkles,
Zap,
Activity,
} from "lucide-react";
import { InlineSpinner } from "@/components/ui/loading-spinner";
/**
* @description KIS 인증 입력 폼
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
* @description KIS 인증 입력 폼 (Minimal Redesign v4)
* - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해"
* - Compact Width: max-w-lg + mx-auto
* - Monochrome Mock Mode: Blue -> Zinc/Gray
*/
export function KisAuthForm() {
const {
@@ -54,6 +60,11 @@ export function KisAuthForm() {
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
// 입력 필드 Focus 상태 관리를 위한 State
const [focusedField, setFocusedField] = useState<
"appKey" | "appSecret" | null
>(null);
function handleValidate() {
startValidateTransition(async () => {
try {
@@ -67,7 +78,6 @@ export function KisAuthForm() {
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
}
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
const credentials = {
appKey,
appSecret,
@@ -114,123 +124,191 @@ export function KisAuthForm() {
}
return (
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background dark:border-brand-700/50 dark:from-brand-900/35 dark:to-background">
<CardHeader>
<CardTitle className="text-foreground dark:text-brand-50">KIS API </CardTitle>
<CardDescription className="text-muted-foreground dark:text-brand-100/80">
, API .
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ========== CREDENTIAL INPUTS ========== */}
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground dark:text-brand-100/72">
</label>
<div className="flex gap-2">
<Button
type="button"
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
className={cn(
"flex-1 border transition-all",
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
kisTradingEnvInput === "real"
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
<div className="group relative mx-auto w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
{/* Inner Content Container - Compact spacing */}
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<KeyRound className="h-4.5 w-4.5" />
</div>
<div>
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
KIS API Key Connection
{isKisVerified && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
Connected
</span>
)}
onClick={() => setKisTradingEnvInput("real")}
>
</Button>
<Button
type="button"
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
className={cn(
"flex-1 border transition-all",
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
kisTradingEnvInput === "mock"
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
)}
onClick={() => setKisTradingEnvInput("mock")}
>
</Button>
</h2>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
API .
</p>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Key
</label>
{/* Trading Mode Switch (Segmented Control - Compact) */}
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
<button
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "real"
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Zap
className={cn(
"h-3 w-3",
kisTradingEnvInput === "real"
? "text-brand-500 dark:text-white"
: "text-zinc-400",
)}
/>
</button>
<button
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
kisTradingEnvInput === "mock"
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
)}
>
<Activity
className={cn(
"h-3 w-3",
kisTradingEnvInput === "mock"
? "text-zinc-800 dark:text-zinc-200"
: "text-zinc-400",
)}
/>
</button>
</div>
</div>
{/* Input Fields Section (Compact Stacked Layout) */}
<div className="flex flex-col gap-2">
{/* App Key Input */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appKey"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Shield className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
App Key
</div>
<Input
type="password"
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
onFocus={() => setFocusedField("appKey")}
onBlur={() => setFocusedField(null)}
placeholder="App Key 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Secret
</label>
{/* App Secret Input */}
<div
className={cn(
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
focusedField === "appSecret"
? "border-brand-500 ring-1 ring-brand-500"
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
)}
>
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
<Lock className="h-3.5 w-3.5" />
</div>
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
Secret
</div>
<Input
type="password"
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
onFocus={() => setFocusedField("appSecret")}
onBlur={() => setFocusedField(null)}
placeholder="App Secret 입력"
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
autoComplete="off"
/>
</div>
</div>
{/* ========== ACTIONS ========== */}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleValidate}
disabled={
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
}
className="bg-brand-600 hover:bg-brand-700"
>
{isValidating ? "검증 중..." : "API 키 검증"}
</Button>
{/* Action & Status Section */}
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
<div className="flex gap-2">
<Button
onClick={handleValidate}
disabled={
isValidating ||
!kisAppKeyInput.trim() ||
!kisAppSecretInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="text-white h-3 w-3" />
</span>
) : (
<span className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
API
</span>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevoke}
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800 dark:border-brand-700/60 dark:text-brand-200 dark:hover:bg-brand-900/35 dark:hover:text-brand-100"
>
{isRevoking ? "해제 중..." : "연결 끊기"}
</Button>
{isKisVerified && (
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking}
className="h-9 rounded-lg border-zinc-200 bg-white text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{isRevoking ? "해제 중" : "연결 해제"}
</Button>
)}
</div>
{isKisVerified ? (
<span className="flex items-center text-sm font-medium text-brand-700 dark:text-brand-200">
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
{/* Status Messages - Compact */}
<div className="flex-1 text-right">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!errorMessage && !statusMessage && !isKisVerified && (
<p className="flex justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
</div>
</div>
{errorMessage && (
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
)}
{statusMessage && (
<div className="text-sm text-brand-700 dark:text-brand-200">{statusMessage}</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -8,6 +8,8 @@ interface AnimatedQuantityProps {
className?: string;
/** 값 변동 시 배경 깜빡임 */
useColor?: boolean;
/** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */
side?: "ask" | "bid";
}
/**
@@ -18,6 +20,7 @@ export function AnimatedQuantity({
format = (v) => v.toLocaleString(),
className,
useColor = false,
side = "bid",
}: AnimatedQuantityProps) {
const prevRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
@@ -59,32 +62,41 @@ export function AnimatedQuantity({
transition={{ duration: 1 }}
className={cn(
"absolute inset-0 z-0 rounded-sm",
flash === "up" ? "bg-red-200/50" : "bg-brand-200/50",
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
)}
/>
)}
</AnimatePresence>
{/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */}
{side === "ask" && <DiffChange diff={diff} />}
{/* 수량 값 */}
<span className="relative z-10">{format(value)}</span>
{/* ±diff (인라인 표시) */}
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
diff > 0 ? "text-red-500" : "text-brand-600",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
{/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */}
{side !== "ask" && <DiffChange diff={diff} />}
</span>
);
}
function DiffChange({ diff }: { diff: number | null }) {
return (
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none tabular-nums",
diff > 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
);
}

View File

@@ -309,6 +309,7 @@ function BookSideRows({
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
</>
@@ -323,7 +324,11 @@ function BookSideRows({
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
)}
>
<span className={isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"}>
<span
className={
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
@@ -349,6 +354,7 @@ function BookSideRows({
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
</>
@@ -451,7 +457,9 @@ function Row({
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">{label}</span>
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 font-medium tabular-nums",

View File

@@ -47,9 +47,9 @@ function resolveOrderBookTrId(
}
/**
* @description KIS 실시간 체결/호가를 단일 WebSocket으로 구독합니다.
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket 호출
* @see lib/kis/domestic-market-session.ts 장 세션 계산 및 테스트용 override
* @description Subscribes trade ticks and orderbook over one websocket.
* @see features/dashboard/components/DashboardContainer.tsx
* @see lib/kis/domestic-market-session.ts
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
@@ -83,7 +83,6 @@ export function useKisTradeWebSocket(
? resolveTradeTrId(credentials.tradingEnv, marketSession)
: TRADE_TR_ID;
// KST 장 세션을 주기적으로 재평가합니다.
useEffect(() => {
const timerId = window.setInterval(() => {
const nextSession = resolveSessionInClient();
@@ -93,13 +92,12 @@ export function useKisTradeWebSocket(
return () => window.clearInterval(timerId);
}, []);
// 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다.
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.",
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
);
}, 8000);
@@ -134,22 +132,18 @@ export function useKisTradeWebSocket(
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
const wsConnection = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
.getOrFetchWsConnection();
if (!approvalKey) {
if (!wsConnection) {
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
}
if (disposed) return;
approvalKeyRef.current = approvalKey;
approvalKeyRef.current = wsConnection.approvalKey;
const wsBase =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`);
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
socketRef.current = socket;
socket.onopen = () => {

View File

@@ -1,15 +1,15 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { createJSONStorage, persist } from "zustand/middleware";
import { create } from "zustand";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
* @description Stores KIS input, verification, and websocket connection state.
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
@@ -17,73 +17,37 @@ export interface KisRuntimeCredentials {
accountNo: string;
}
interface KisWsConnection {
approvalKey: string;
wsUrl: string;
}
interface KisRuntimeStoreState {
// [State] 입력 폼 상태
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
// [State] 검증/연동 상태
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
// [State] 웹소켓 승인키
wsApprovalKey: string | null;
wsUrl: string | null;
}
interface KisRuntimeStoreActions {
/**
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param tradingEnv 거래 모드
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
*/
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
/**
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appKey 앱 키
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
*/
setKisAppKeyInput: (appKey: string) => void;
/**
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appSecret 앱 시크릿
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
*/
setKisAppSecretInput: (appSecret: string) => void;
/**
* 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param accountNo 계좌번호 (8자리-2자리)
*/
setKisAccountNoInput: (accountNo: string) => void;
/**
* 검증 성공 상태를 저장합니다.
* @param credentials 검증 완료된 키
* @param tradingEnv 현재 연동 모드
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
*/
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
/**
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
*/
invalidateKisVerification: () => void;
/**
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
* @param tradingEnv 표시용 모드
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
*/
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
/**
* 웹소켓 승인키를 가져오거나 없으면 발급받습니다.
* @returns approvalKey
*/
getOrFetchApprovalKey: () => Promise<string | null>;
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
}
const INITIAL_STATE: KisRuntimeStoreState = {
@@ -95,11 +59,22 @@ const INITIAL_STATE: KisRuntimeStoreState = {
isKisVerified: false,
tradingEnv: "real",
wsApprovalKey: null,
wsUrl: null,
};
// 동시 요청 방지를 위한 모듈 스코프 변수
let approvalPromise: Promise<string | null> | null = null;
const RESET_VERIFICATION_STATE = {
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
wsUrl: null,
};
let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
/**
* @description Runtime store for KIS session.
* @see features/dashboard/components/auth/KisAuthForm.tsx
*/
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
@@ -110,33 +85,25 @@ export const useKisRuntimeStore = create<
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
...RESET_VERIFICATION_STATE,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
...RESET_VERIFICATION_STATE,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
...RESET_VERIFICATION_STATE,
}),
setKisAccountNoInput: (accountNo) =>
set({
kisAccountNoInput: accountNo,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
...RESET_VERIFICATION_STATE,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
@@ -144,15 +111,13 @@ export const useKisRuntimeStore = create<
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
// 인증이 바뀌면 승인키도 초기화
wsApprovalKey: null,
wsUrl: null,
}),
invalidateKisVerification: () =>
set({
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
...RESET_VERIFICATION_STATE,
}),
clearKisRuntimeSession: (tradingEnv) =>
@@ -161,48 +126,52 @@ export const useKisRuntimeStore = create<
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
...RESET_VERIFICATION_STATE,
tradingEnv,
wsApprovalKey: null,
}),
getOrFetchApprovalKey: async () => {
const { wsApprovalKey, verifiedCredentials } = get();
getOrFetchWsConnection: async () => {
const { wsApprovalKey, wsUrl, verifiedCredentials } = get();
// 1. 이미 키가 있으면 반환
if (wsApprovalKey) {
return wsApprovalKey;
if (wsApprovalKey && wsUrl) {
return { approvalKey: wsApprovalKey, wsUrl };
}
// 2. 인증 정보가 없으면 실패
if (!verifiedCredentials) {
return null;
}
// 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping)
if (approvalPromise) {
return approvalPromise;
if (wsConnectionPromise) {
return wsConnectionPromise;
}
// 4. API 호출
approvalPromise = (async () => {
wsConnectionPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (data.approvalKey) {
set({ wsApprovalKey: data.approvalKey });
return data.approvalKey;
if (!data.approvalKey || !data.wsUrl) {
return null;
}
return null;
const nextConnection = {
approvalKey: data.approvalKey,
wsUrl: data.wsUrl,
} satisfies KisWsConnection;
set({
wsApprovalKey: nextConnection.approvalKey,
wsUrl: nextConnection.wsUrl,
});
return nextConnection;
} catch (error) {
console.error(error);
return null;
} finally {
approvalPromise = null;
wsConnectionPromise = null;
}
})();
return approvalPromise;
return wsConnectionPromise;
},
}),
{
@@ -216,13 +185,7 @@ export const useKisRuntimeStore = create<
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
// wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리)
// 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나,
// 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방.
// 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음.
// 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외)
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
}),
},
),

View File

@@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
interface LogoProps {
className?: string;
variant?: "symbol" | "full";
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
blendWithBackground?: boolean;
}
export function Logo({
className,
variant = "full",
blendWithBackground = false,
}: LogoProps) {
// 색상 클래스 정의
const mainColorClass = blendWithBackground
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
return (
<div
className={cn("relative flex items-center gap-2 select-none", className)}
aria-label="Jurini Logo"
>
<svg
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"shrink-0",
variant === "full" ? "h-10 w-10" : "h-full w-full",
)}
>
<defs>
{/* Mask for the cutout effect around the arrow */}
<mask id="arrow-cutout">
<rect width="100%" height="100%" fill="white" />
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
stroke="black"
strokeWidth="12"
strokeLinecap="round"
/>
{/* Arrow Head Cutout */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="black"
stroke="black"
strokeWidth="6"
strokeLinejoin="round"
transform="rotate(-15 90 25)"
/>
</mask>
</defs>
{/* ========== BARS (Masked) ========== */}
<g
mask="url(#arrow-cutout)"
className={
blendWithBackground
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
: "fill-brand-600 dark:fill-brand-500"
}
>
{/* Bar 1 (Left, Short) */}
<rect x="15" y="45" width="18" height="40" rx="4" />
{/* Bar 2 (Middle, Medium) */}
<rect x="41" y="30" width="18" height="55" rx="4" />
{/* Bar 3 (Right, Tall) */}
<rect x="67" y="10" width="18" height="75" rx="4" />
</g>
{/* ========== ARROW (Foreground) ========== */}
<g className={mainColorClass}>
{/* Arrow Path */}
<path
d="M10 75 C 35 45, 55 85, 90 25"
fill="none"
strokeWidth="7"
strokeLinecap="round"
/>
{/* Arrow Head */}
<path
d="M90 25 L 78 32 L 85 42 Z"
fill="currentColor"
stroke="none"
transform="rotate(-15 90 25)"
/>
</g>
</svg>
{/* ========== TEXT (Optional) ========== */}
{variant === "full" && (
<span
className={cn(
"font-bold tracking-tight",
blendWithBackground
? "text-white opacity-95"
: "text-brand-900 dark:text-brand-50",
)}
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
>
JOORIN-E
</span>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils";
import { Logo } from "@/features/layout/components/Logo";
interface HeaderProps {
/** 현재 로그인 사용자 정보(null 가능) */
@@ -59,40 +60,29 @@ export function Header({
)}
>
{/* ========== LEFT: LOGO SECTION ========== */}
<Link
href={AUTH_ROUTES.HOME}
className={cn("group flex items-center gap-2", blendWithBackground ? "text-white" : "")}
>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-110",
blendWithBackground ? "bg-brand-500/45 ring-1 ring-white/55" : "bg-primary/10",
)}
>
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
</div>
<span
className={cn(
"text-xl font-bold tracking-tight transition-colors",
blendWithBackground
? "!text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.75)] group-hover:text-brand-100"
: "text-foreground group-hover:text-primary",
)}
>
Jurini
</span>
{/* ========== LEFT: LOGO SECTION ========== */}
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
<Logo
variant="full"
className="h-10 text-xl transition-transform duration-200 group-hover:scale-105"
blendWithBackground={blendWithBackground}
/>
</Link>
{/* ========== RIGHT: ACTION SECTION ========== */}
<div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
<div
className={cn(
"flex items-center gap-2 sm:gap-3",
blendWithBackground ? "text-white" : "",
)}
>
<ThemeToggle
className={cn(
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
: "",
)}
iconClassName={blendWithBackground ? "!text-white" : undefined}
iconClassName={blendWithBackground ? "text-white!" : undefined}
/>
{user ? (
@@ -107,7 +97,7 @@ export function Header({
className={cn(
"hidden font-medium sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white"
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:text-white!"
: "",
)}
>
@@ -126,7 +116,7 @@ export function Header({
className={cn(
"hidden sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white"
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 hover:text-white!"
: "",
)}
>