디자인 변경
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user