테마 적용
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [비밀번호 찾기 페이지]
|
* [비밀번호 찾기 페이지]
|
||||||
@@ -31,10 +32,10 @@ export default async function ForgotPasswordPage({
|
|||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{message && <FormMessage message={message} />}
|
{message && <FormMessage message={message} />}
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||||
<CardHeader className="space-y-3 text-center">
|
<CardHeader className="space-y-3 text-center">
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
<span className="text-sm font-semibold">MAIL</span>
|
<Mail className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
비밀번호 재설정
|
비밀번호 재설정
|
||||||
@@ -59,13 +60,13 @@ export default async function ForgotPasswordPage({
|
|||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
formAction={requestPasswordReset}
|
formAction={requestPasswordReset}
|
||||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
>
|
>
|
||||||
재설정 링크 보내기
|
재설정 링크 보내기
|
||||||
</Button>
|
</Button>
|
||||||
@@ -74,7 +75,7 @@ export default async function ForgotPasswordPage({
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link
|
||||||
href={AUTH_ROUTES.LOGIN}
|
href={AUTH_ROUTES.LOGIN}
|
||||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
>
|
>
|
||||||
로그인 페이지로 돌아가기
|
로그인 페이지로 돌아가기
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -12,17 +12,18 @@ export default async function AuthLayout({
|
|||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
|
||||||
{/* ========== 헤더 (홈 이동용) ========== */}
|
{/* ========== 헤더 (홈 이동용) ========== */}
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
{/* ========== 배경 그라디언트 레이어 ========== */}
|
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
|
||||||
|
|
||||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
<div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
|
||||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
<div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
|
||||||
|
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
|
||||||
|
|
||||||
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
|
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
|
||||||
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
|
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import LoginForm from "@/features/auth/components/login-form";
|
import LoginForm from "@/features/auth/components/login-form";
|
||||||
|
import { LogIn } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [로그인 페이지 컴포넌트]
|
* [로그인 페이지 컴포넌트]
|
||||||
*
|
*
|
||||||
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
|
||||||
* - 투명 배경 + 블러 효과로 깊이감 표현
|
* - 보라색 그라디언트 아이콘 배지
|
||||||
* - 그라디언트 배경으로 생동감 추가
|
|
||||||
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||||
*
|
*
|
||||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
@@ -23,36 +23,25 @@ export default async function LoginPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ message: string }>;
|
searchParams: Promise<{ message: string }>;
|
||||||
}) {
|
}) {
|
||||||
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
|
||||||
const { message } = await searchParams;
|
const { message } = await searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{/* 에러/성공 메시지 표시 영역 */}
|
|
||||||
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
|
||||||
<FormMessage message={message} />
|
<FormMessage message={message} />
|
||||||
|
|
||||||
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||||
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
|
||||||
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
|
||||||
{/* ========== 카드 헤더 영역 ========== */}
|
|
||||||
<CardHeader className="space-y-3 text-center">
|
<CardHeader className="space-y-3 text-center">
|
||||||
{/* 아이콘 배경: 그라디언트 원형 */}
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
<LogIn className="h-7 w-7 text-white" />
|
||||||
<span className="text-4xl">👋</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
환영합니다!
|
환영합니다!
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{/* 페이지 설명 */}
|
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
서비스 이용을 위해 로그인해 주세요.
|
서비스 이용을 위해 로그인해 주세요.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [비밀번호 재설정 페이지]
|
* [비밀번호 재설정 페이지]
|
||||||
@@ -39,10 +40,10 @@ export default async function ResetPasswordPage({
|
|||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{message && <FormMessage message={message} />}
|
{message && <FormMessage message={message} />}
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||||
<CardHeader className="space-y-3 text-center">
|
<CardHeader className="space-y-3 text-center">
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
<span className="text-sm font-semibold">PW</span>
|
<KeyRound className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
비밀번호 재설정
|
비밀번호 재설정
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
export default async function SignupPage({
|
export default async function SignupPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -19,13 +20,12 @@ export default async function SignupPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{/* 메시지 알림 */}
|
|
||||||
<FormMessage message={message} />
|
<FormMessage message={message} />
|
||||||
|
|
||||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||||
<CardHeader className="space-y-3 text-center">
|
<CardHeader className="space-y-3 text-center">
|
||||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
<span className="text-4xl">🚀</span>
|
<UserPlus className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
회원가입
|
회원가입
|
||||||
@@ -35,16 +35,14 @@ export default async function SignupPage({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* ========== 폼 영역 ========== */}
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<SignupForm />
|
<SignupForm />
|
||||||
|
|
||||||
{/* ========== 로그인 링크 ========== */}
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
이미 계정이 있으신가요?{" "}
|
이미 계정이 있으신가요?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={AUTH_ROUTES.LOGIN}
|
href={AUTH_ROUTES.LOGIN}
|
||||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
>
|
>
|
||||||
로그인 하러 가기
|
로그인 하러 가기
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, BarChart3, ShieldCheck, Sparkles, Zap } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
import { Header } from "@/features/layout/components/header";
|
import { Header } from "@/features/layout/components/header";
|
||||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,6 +20,85 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import ShaderBackground from "@/components/ui/shader-background";
|
import ShaderBackground from "@/components/ui/shader-background";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
interface ValuePoint {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureItem {
|
||||||
|
icon: LucideIcon;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartStep {
|
||||||
|
step: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_POINTS: ValuePoint[] = [
|
||||||
|
{
|
||||||
|
value: "3분",
|
||||||
|
label: "초기 세팅 시간",
|
||||||
|
detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "24시간",
|
||||||
|
label: "실시간 시장 관제",
|
||||||
|
detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "0원",
|
||||||
|
label: "가입 시작 비용",
|
||||||
|
detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEATURE_ITEMS: FeatureItem[] = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
eyebrow: "Risk Guard",
|
||||||
|
title: "손실 방어를 먼저 설계합니다",
|
||||||
|
description:
|
||||||
|
"진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
eyebrow: "Data Momentum",
|
||||||
|
title: "데이터로 타점을 좁힙니다",
|
||||||
|
description:
|
||||||
|
"실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
eyebrow: "Auto Execution",
|
||||||
|
title: "기회가 오면 즉시 자동 실행합니다",
|
||||||
|
description:
|
||||||
|
"규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const START_STEPS: StartStep[] = [
|
||||||
|
{
|
||||||
|
step: "STEP 01",
|
||||||
|
title: "계정 연결",
|
||||||
|
description: "안내에 따라 거래 계정을 안전하게 연결합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "STEP 02",
|
||||||
|
title: "성향 선택",
|
||||||
|
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "STEP 03",
|
||||||
|
title: "자동 실행 시작",
|
||||||
|
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 홈 메인 랜딩 페이지
|
* 홈 메인 랜딩 페이지
|
||||||
* @returns 랜딩 UI
|
* @returns 랜딩 UI
|
||||||
@@ -24,138 +111,236 @@ export default async function HomePage() {
|
|||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
|
||||||
|
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||||
|
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기";
|
||||||
|
const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN;
|
||||||
|
const secondaryCtaLabel = user ? "실시간 상태 확인하기" : "기존 계정으로 로그인";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
|
<div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
|
||||||
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
|
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
|
||||||
|
|
||||||
<main className="relative isolate flex-1 pt-16">
|
<main className="relative isolate flex-1 overflow-hidden pt-16">
|
||||||
{/* ========== SHADER BACKGROUND SECTION ========== */}
|
{/* ========== SHADER BACKGROUND SECTION ========== */}
|
||||||
<ShaderBackground opacity={1} className="-z-20" />
|
<ShaderBackground opacity={1} className="-z-20" />
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-40 top-40 -z-10 h-96 w-96 rounded-full bg-brand-500/20 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -right-24 top-72 -z-10 h-[26rem] w-[26rem] rounded-full bg-brand-300/20 blur-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ========== HERO SECTION ========== */}
|
{/* ========== HERO SECTION ========== */}
|
||||||
<section className="container mx-auto max-w-7xl px-4 pb-10 pt-16 md:pt-24">
|
<section className="container mx-auto max-w-7xl px-4 pb-14 pt-14 md:pt-24">
|
||||||
<div className="p-2 md:p-6">
|
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
|
||||||
<div className="mx-auto max-w-4xl text-center">
|
<div className="lg:col-span-7">
|
||||||
<span className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-brand-200 [text-shadow:0_2px_24px_rgba(0,0,0,0.65)]">
|
<span className="inline-flex animate-in fade-in-0 items-center gap-2 rounded-full border border-brand-400/40 bg-brand-500/15 px-4 py-1.5 text-xs font-semibold tracking-wide text-brand-100 backdrop-blur-md duration-700">
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
Shader Background Landing
|
초보 투자자를 위한 전략 자동매매 파트너
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<h1 className="mt-5 text-4xl font-black tracking-tight text-white [text-shadow:0_4px_30px_rgba(0,0,0,0.6)] md:text-6xl">
|
<h1 className="mt-5 animate-in slide-in-from-bottom-4 text-4xl font-black tracking-tight text-white [text-shadow:0_6px_40px_rgba(0,0,0,0.55)] duration-700 md:text-6xl">
|
||||||
데이터로 판단하고
|
감이 아닌 전략으로,
|
||||||
<br />
|
<br />
|
||||||
<span className="bg-linear-to-r from-brand-300 via-brand-400 to-brand-500 bg-clip-text text-transparent">
|
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
|
||||||
자동으로 실행합니다
|
내 계좌의 성장 루틴을 만드세요
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mx-auto mt-5 max-w-2xl text-sm leading-relaxed text-white/80 [text-shadow:0_2px_16px_rgba(0,0,0,0.5)] md:text-lg">
|
<p className="mt-5 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/80 duration-700 md:text-lg">
|
||||||
실시간 시세 확인, 전략 점검, 주문 연결까지 한 화면에서 이어지는 자동매매 환경을
|
주린이는 보호와 성장의 균형을 먼저 설계합니다.
|
||||||
제공합니다. 복잡한 설정은 줄이고 실행 속도는 높였습니다.
|
<br />
|
||||||
|
손실 방어 규칙, 데이터 신호, 자동 실행을 하나의 흐름으로 묶어 초보의 첫 수익 루틴을 만듭니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
|
||||||
{/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */}
|
<Button
|
||||||
{user ? (
|
asChild
|
||||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
size="lg"
|
||||||
<Link href={AUTH_ROUTES.DASHBOARD}>
|
className="group h-12 rounded-full bg-linear-to-r from-brand-500 via-brand-400 to-brand-600 px-8 text-base font-semibold text-white shadow-2xl shadow-brand-800/45 [background-size:200%_200%] animate-gradient-x hover:brightness-110"
|
||||||
대시보드 바로가기
|
>
|
||||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
<Link href={primaryCtaHref}>
|
||||||
</Link>
|
{primaryCtaLabel}
|
||||||
</Button>
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
|
|
||||||
<Link href={AUTH_ROUTES.SIGNUP}>
|
|
||||||
무료로 시작하기
|
|
||||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-12 rounded-full border-white/40 bg-transparent px-8 text-base text-white hover:bg-white/10 hover:text-white"
|
className="h-12 rounded-full border-white/35 bg-black/30 px-8 text-base text-white backdrop-blur-md hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
<Link href={AUTH_ROUTES.LOGIN}>로그인</Link>
|
<Link href={secondaryCtaHref}>{secondaryCtaLabel}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3 text-xs text-white/70">
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
가입 3분
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
카드 등록 없음
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
언제든 전략 변경 가능
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
<div className="relative lg:col-span-5">
|
||||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
<div className="absolute -inset-px rounded-3xl bg-linear-to-br from-brand-300/50 via-brand-600/0 to-brand-600/60 blur-lg" />
|
||||||
<p className="text-xs text-white/70">지연 시간 기준</p>
|
<div className="relative overflow-hidden rounded-3xl border border-white/15 bg-black/35 p-6 shadow-[0_30px_90px_-45px_rgba(0,0,0,0.85)] backdrop-blur-2xl">
|
||||||
<p className="mt-1 text-lg font-bold">Low Latency</p>
|
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-brand-200">LIVE STRATEGY STATUS</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">실시간 자동매매 관제</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
<span className="inline-flex items-center gap-1 rounded-full bg-brand-500/25 px-3 py-1 text-xs font-semibold text-brand-100">
|
||||||
<p className="text-xs text-white/70">모니터링</p>
|
<Activity className="h-3.5 w-3.5" />
|
||||||
<p className="mt-1 text-lg font-bold">실시간 시세 반영</p>
|
안정 운영중
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
|
|
||||||
<p className="text-xs text-white/70">실행 환경</p>
|
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
|
||||||
<p className="mt-1 text-lg font-bold">웹 기반 자동매매</p>
|
<div className="mt-5 grid gap-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">리스크 룰 적용</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">손실 제한 + 분할 대응 활성화</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">데이터 신호 감시</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">시장 흐름 조건 충족 시 자동 진입</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">실행 상태</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">체결 지연 최소화, 규칙 기반 지속 운영</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2 text-xs text-brand-100/90">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-brand-300" />
|
||||||
|
당신의 전략은 설정한 원칙 안에서만 실행됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
{VALUE_POINTS.map((point) => (
|
||||||
|
<div
|
||||||
|
key={point.label}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<p className="text-2xl font-black text-brand-100">{point.value}</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{point.label}</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-white/70">{point.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ========== FEATURE SECTION ========== */}
|
{/* ========== FEATURE SECTION ========== */}
|
||||||
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
<p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
|
||||||
<CardHeader>
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
초보에게 필요한 건 복잡함이 아니라
|
||||||
<BarChart3 className="h-5 w-5" />
|
<br />
|
||||||
|
<span className="text-brand-200">기준이 분명한 자동매매</span>입니다
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">실시간 데이터 가시화</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
|
||||||
시세 변화와 거래 흐름을 빠르게 확인할 수 있게 핵심 정보만 선별해 보여줍니다.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
<div className="mt-8 grid gap-6 md:grid-cols-3">
|
||||||
<CardHeader>
|
{FEATURE_ITEMS.map((feature) => {
|
||||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
const FeatureIcon = feature.icon;
|
||||||
<Zap className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">전략 실행 속도 최적화</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
|
||||||
필요한 단계만 남긴 단순한 흐름으로 전략 테스트와 실행 전환 시간을 줄였습니다.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-0 bg-transparent text-white shadow-none">
|
return (
|
||||||
|
<Card
|
||||||
|
key={feature.title}
|
||||||
|
className="group border-white/10 bg-black/25 text-white shadow-none backdrop-blur-md transition-all hover:-translate-y-1 hover:border-brand-400/40 hover:bg-black/35"
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
|
<div className="mb-2 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500/20 text-brand-200 transition-transform group-hover:scale-110">
|
||||||
<ShieldCheck className="h-5 w-5" />
|
<FeatureIcon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]">명확한 리스크 관리</CardTitle>
|
<p className="text-xs font-semibold tracking-wide text-brand-200">{feature.eyebrow}</p>
|
||||||
|
<CardTitle className="text-xl leading-snug text-white">{feature.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||||
자동매매에서 중요한 손실 한도와 조건을 먼저 정의하고 일관되게 적용할 수 있습니다.
|
{feature.description}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== START STEP SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-7xl px-4 pb-16">
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
|
||||||
|
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
|
가입부터 자동 실행까지
|
||||||
|
<br />
|
||||||
|
<span className="text-brand-200">딱 3단계면 충분합니다</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-white/70">
|
||||||
|
어려운 설정 화면 대신, 따라 하기 쉬운 단계로 바로 시작할 수 있게 구성했습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
{START_STEPS.map((item) => (
|
||||||
|
<div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5">
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-white">{item.title}</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ========== CTA SECTION ========== */}
|
{/* ========== CTA SECTION ========== */}
|
||||||
<section className="container mx-auto max-w-7xl px-4 pb-20">
|
<section className="container mx-auto max-w-7xl px-4 pb-20">
|
||||||
<div className="p-2 md:p-4">
|
<div className="relative overflow-hidden rounded-3xl border border-brand-300/30 bg-linear-to-r from-brand-600/30 via-brand-500/20 to-brand-700/30 p-8 backdrop-blur-xl md:p-12">
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-brand-200 [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]">준비되면 바로 시작하세요</p>
|
<p className="text-sm font-semibold text-brand-100">수익의 시작은 빠를수록 좋습니다</p>
|
||||||
<h2 className="mt-1 text-2xl font-bold tracking-tight text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.45)] md:text-3xl">
|
<h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
AutoTrade에서 전략을 실행해 보세요
|
지금 가입하고,
|
||||||
|
<br />
|
||||||
|
내 전략을 오늘부터 자동 실행하세요
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mt-3 text-sm text-white/75 md:text-base">
|
||||||
|
주린이가 첫 설정부터 실행까지 함께 안내합니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild className="h-11 rounded-full bg-primary px-7 hover:bg-primary/90">
|
|
||||||
<Link href={user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP}>
|
<Button
|
||||||
{user ? "대시보드 열기" : "회원가입 시작"}
|
asChild
|
||||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
size="lg"
|
||||||
|
className="group h-14 rounded-full bg-white px-9 text-lg font-bold text-brand-700 shadow-xl shadow-black/35 hover:bg-white/90"
|
||||||
|
>
|
||||||
|
<Link href={primaryCtaHref}>
|
||||||
|
지금 시작하기
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default async function MainLayout({
|
|||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
<div className="flex flex-1 pt-16">
|
<div className="flex flex-1 pt-16">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
101
app/globals.css
101
app/globals.css
@@ -38,16 +38,16 @@
|
|||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-brand-50: oklch(0.97 0.02 294);
|
--color-brand-50: var(--brand-50);
|
||||||
--color-brand-100: oklch(0.93 0.05 294);
|
--color-brand-100: var(--brand-100);
|
||||||
--color-brand-200: oklch(0.87 0.1 294);
|
--color-brand-200: var(--brand-200);
|
||||||
--color-brand-300: oklch(0.79 0.15 294);
|
--color-brand-300: var(--brand-300);
|
||||||
--color-brand-400: oklch(0.7 0.2 294);
|
--color-brand-400: var(--brand-400);
|
||||||
--color-brand-500: oklch(0.62 0.24 294);
|
--color-brand-500: var(--brand-500);
|
||||||
--color-brand-600: oklch(0.56 0.26 294);
|
--color-brand-600: var(--brand-600);
|
||||||
--color-brand-700: oklch(0.49 0.24 295);
|
--color-brand-700: var(--brand-700);
|
||||||
--color-brand-800: oklch(0.4 0.2 296);
|
--color-brand-800: var(--brand-800);
|
||||||
--color-brand-900: oklch(0.33 0.14 297);
|
--color-brand-900: var(--brand-900);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -71,6 +71,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* BRAND PALETTE CONTROL
|
||||||
|
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
|
||||||
|
*/
|
||||||
|
/* 초기 브랜드 보라값(원본 기준) */
|
||||||
|
--brand-50: oklch(0.97 0.02 294);
|
||||||
|
--brand-100: oklch(0.93 0.05 294);
|
||||||
|
--brand-200: oklch(0.87 0.1 294);
|
||||||
|
--brand-300: oklch(0.79 0.15 294);
|
||||||
|
--brand-400: oklch(0.7 0.2 294);
|
||||||
|
--brand-500: oklch(0.62 0.24 294);
|
||||||
|
--brand-600: oklch(0.56 0.26 294);
|
||||||
|
--brand-700: oklch(0.49 0.24 295);
|
||||||
|
--brand-800: oklch(0.4 0.2 296);
|
||||||
|
--brand-900: oklch(0.33 0.14 297);
|
||||||
|
|
||||||
|
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
|
||||||
|
--brand-chart-background-light: #ffffff;
|
||||||
|
--brand-chart-background-dark: #17131e;
|
||||||
|
--brand-chart-text-light: #6b21a8;
|
||||||
|
--brand-chart-text-dark: #e9d5ff;
|
||||||
|
--brand-chart-border-light: #e9d5ff;
|
||||||
|
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
|
||||||
|
--brand-chart-grid-light: #f3e8ff;
|
||||||
|
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
|
||||||
|
--brand-chart-crosshair-light: #c084fc;
|
||||||
|
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
|
||||||
|
|
||||||
|
--brand-chart-background: #ffffff;
|
||||||
|
--brand-chart-down: #2563eb;
|
||||||
|
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
|
||||||
|
--brand-chart-text: #6b21a8;
|
||||||
|
--brand-chart-border: var(--brand-chart-border-light);
|
||||||
|
--brand-chart-grid: var(--brand-chart-grid-light);
|
||||||
|
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
|
||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
@@ -78,7 +113,7 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.56 0.26 294);
|
--primary: var(--brand-600);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
@@ -89,7 +124,7 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.62 0.24 294);
|
--ring: var(--brand-500);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@@ -97,7 +132,7 @@
|
|||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.56 0.26 294);
|
--sidebar-primary: var(--brand-600);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
@@ -106,37 +141,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
/* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
|
||||||
|
--background: oklch(0.17 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.235 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.235 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.56 0.26 294);
|
--primary: var(--brand-600);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.285 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.285 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.83 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.285 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 18%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 22%);
|
||||||
--ring: oklch(0.62 0.24 294);
|
--ring: var(--brand-500);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.235 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.56 0.26 294);
|
--sidebar-primary: var(--brand-600);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.285 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 18%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.78 0 0);
|
||||||
|
|
||||||
|
/* 다크 테마용 차트 배경/격자 대비 */
|
||||||
|
--brand-chart-background: var(--brand-chart-background-dark);
|
||||||
|
--brand-chart-text: var(--brand-chart-text-dark);
|
||||||
|
--brand-chart-border: var(--brand-chart-border-dark);
|
||||||
|
--brand-chart-grid: var(--brand-chart-grid-dark);
|
||||||
|
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ const outfit = Outfit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "AutoTrade",
|
title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
|
||||||
description: "Automated Crypto Trading Platform",
|
description:
|
||||||
|
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file components/theme-toggle.tsx
|
* @file components/theme-toggle.tsx
|
||||||
* @description 라이트/다크/시스템 테마 전환 토글 버튼
|
* @description 라이트/다크 테마 즉시 전환 토글 버튼
|
||||||
* @remarks
|
* @remarks
|
||||||
* - [레이어] Components/UI
|
* - [레이어] Components/UI
|
||||||
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영
|
* - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
|
||||||
* - [연관 파일] theme-provider.tsx (next-themes)
|
* - [연관 파일] theme-provider.tsx (next-themes)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,12 +15,6 @@ import { useTheme } from "next-themes";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
interface ThemeToggleProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -30,24 +24,34 @@ interface ThemeToggleProps {
|
|||||||
/**
|
/**
|
||||||
* 테마 토글 컴포넌트
|
* 테마 토글 컴포넌트
|
||||||
* @remarks next-themes의 useTheme 훅 사용
|
* @remarks next-themes의 useTheme 훅 사용
|
||||||
* @returns Dropdown 메뉴 형태의 테마 선택기
|
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
|
||||||
|
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
|
||||||
*/
|
*/
|
||||||
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||||
const { setTheme } = useTheme();
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const handleToggleTheme = React.useCallback(() => {
|
||||||
|
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<Button
|
||||||
{/* ========== 트리거 버튼 ========== */}
|
type="button"
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="icon" className={className}>
|
size="icon"
|
||||||
{/* 라이트 모드 아이콘 (회전 애니메이션) */}
|
className={className}
|
||||||
|
onClick={handleToggleTheme}
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{/* ========== LIGHT ICON ========== */}
|
||||||
<Sun
|
<Sun
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||||
iconClassName,
|
iconClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* 다크 모드 아이콘 (회전 애니메이션) */}
|
{/* ========== DARK ICON ========== */}
|
||||||
<Moon
|
<Moon
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
||||||
@@ -56,20 +60,5 @@ export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
|||||||
/>
|
/>
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
|
||||||
Light
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
|
||||||
Dark
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
|
||||||
System
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { InlineSpinner } from "@/components/ui/loading-spinner";
|
|||||||
*/
|
*/
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
// ========== 상태 관리 ==========
|
// ========== 상태 관리 ==========
|
||||||
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
|
|
||||||
const [email, setEmail] = useState(() => {
|
const [email, setEmail] = useState(() => {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
return localStorage.getItem("auto-trade-saved-email") || "";
|
return localStorage.getItem("auto-trade-saved-email") || "";
|
||||||
@@ -37,11 +36,6 @@ export default function LoginForm() {
|
|||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// ========== 마운트 시 localStorage 데이터 복구 ==========
|
|
||||||
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
|
|
||||||
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
|
|
||||||
// useState lazy initializer + window guard handles localStorage safely
|
|
||||||
|
|
||||||
// ========== 폼 제출 핸들러 ==========
|
// ========== 폼 제출 핸들러 ==========
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -83,7 +77,7 @@ export default function LoginForm() {
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,7 +96,7 @@ export default function LoginForm() {
|
|||||||
minLength={8}
|
minLength={8}
|
||||||
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
||||||
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,10 +115,9 @@ export default function LoginForm() {
|
|||||||
이메일 기억하기
|
이메일 기억하기
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{/* 비밀번호 찾기 링크 */}
|
|
||||||
<Link
|
<Link
|
||||||
href={AUTH_ROUTES.FORGOT_PASSWORD}
|
href={AUTH_ROUTES.FORGOT_PASSWORD}
|
||||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
>
|
>
|
||||||
비밀번호 찾기
|
비밀번호 찾기
|
||||||
</Link>
|
</Link>
|
||||||
@@ -134,7 +127,7 @@ export default function LoginForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -148,11 +141,11 @@ export default function LoginForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* ========== 회원가입 링크 ========== */}
|
{/* ========== 회원가입 링크 ========== */}
|
||||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
계정이 없으신가요?{" "}
|
계정이 없으신가요?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={AUTH_ROUTES.SIGNUP}
|
href={AUTH_ROUTES.SIGNUP}
|
||||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
>
|
>
|
||||||
회원가입 하기
|
회원가입 하기
|
||||||
</Link>
|
</Link>
|
||||||
@@ -162,7 +155,7 @@ export default function LoginForm() {
|
|||||||
{/* ========== 소셜 로그인 구분선 ========== */}
|
{/* ========== 소셜 로그인 구분선 ========== */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
|
||||||
또는 소셜 로그인
|
또는 소셜 로그인
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +167,7 @@ export default function LoginForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750"
|
className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
|
||||||
>
|
>
|
||||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ export default function ResetPasswordForm() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
8자 이상, 대문자/소문자/숫자/특수문자를 각 1개 이상 포함해야 합니다.
|
8자 이상, 대문자/소문자/숫자/특수문자를 각 1개 이상 포함해야 합니다.
|
||||||
</p>
|
</p>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
@@ -102,7 +102,7 @@ export default function ResetPasswordForm() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
{confirmPassword &&
|
{confirmPassword &&
|
||||||
password !== confirmPassword &&
|
password !== confirmPassword &&
|
||||||
@@ -114,7 +114,7 @@ export default function ResetPasswordForm() {
|
|||||||
{confirmPassword &&
|
{confirmPassword &&
|
||||||
password === confirmPassword &&
|
password === confirmPassword &&
|
||||||
!errors.confirmPassword && (
|
!errors.confirmPassword && (
|
||||||
<p className="text-xs text-green-600 dark:text-green-400">
|
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||||
비밀번호가 일치합니다.
|
비밀번호가 일치합니다.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +128,7 @@ export default function ResetPasswordForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function SignupForm() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">
|
<p className="text-xs text-red-600 dark:text-red-400">
|
||||||
@@ -105,9 +105,9 @@ export default function SignupForm() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
||||||
</p>
|
</p>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
@@ -129,7 +129,7 @@ export default function SignupForm() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
className="h-11 transition-all duration-200"
|
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
||||||
{confirmPassword &&
|
{confirmPassword &&
|
||||||
@@ -143,7 +143,7 @@ export default function SignupForm() {
|
|||||||
{confirmPassword &&
|
{confirmPassword &&
|
||||||
password === confirmPassword &&
|
password === confirmPassword &&
|
||||||
!errors.confirmPassword && (
|
!errors.confirmPassword && (
|
||||||
<p className="text-xs text-green-600 dark:text-green-400">
|
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||||
비밀번호가 일치합니다 ✓
|
비밀번호가 일치합니다 ✓
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +159,7 @@ export default function SignupForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
|
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
|
||||||
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
|
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
|
||||||
|
import { StockSearchHistory } from "@/features/dashboard/components/search/StockSearchHistory";
|
||||||
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
|
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
|
||||||
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
|
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
|
||||||
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
|
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
|
||||||
@@ -20,7 +21,6 @@ import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
|
|||||||
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
|
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
|
||||||
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
|
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
|
||||||
import type {
|
import type {
|
||||||
DashboardStockItem,
|
|
||||||
DashboardStockOrderBookResponse,
|
DashboardStockOrderBookResponse,
|
||||||
DashboardStockSearchItem,
|
DashboardStockSearchItem,
|
||||||
} from "@/features/dashboard/types/dashboard.types";
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
@@ -28,16 +28,18 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* @description 대시보드 메인 컨테이너
|
* @description 대시보드 메인 컨테이너
|
||||||
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
|
||||||
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청 상태를 관리합니다.
|
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
|
||||||
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||||
*/
|
*/
|
||||||
export function DashboardContainer() {
|
export function DashboardContainer() {
|
||||||
const skipNextAutoSearchRef = useRef(false);
|
const skipNextAutoSearchRef = useRef(false);
|
||||||
const hasInitializedAuthPanelRef = useRef(false);
|
const hasInitializedAuthPanelRef = useRef(false);
|
||||||
|
const searchShellRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 모바일에서는 초기 진입 시 API 키 패널을 접어서 본문(차트/호가)을 먼저 보이게 합니다.
|
// 모바일에서는 초기 진입 시 API 패널을 접어 본문(차트/호가)을 먼저 보이게 합니다.
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||||
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
|
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
|
||||||
|
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||||
|
|
||||||
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@@ -50,11 +52,14 @@ export function DashboardContainer() {
|
|||||||
keyword,
|
keyword,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
searchResults,
|
searchResults,
|
||||||
setSearchResults,
|
setSearchError,
|
||||||
setError: setSearchError,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
search,
|
search,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
|
searchHistory,
|
||||||
|
appendSearchHistory,
|
||||||
|
removeSearchHistory,
|
||||||
|
clearSearchHistory,
|
||||||
} = useStockSearch();
|
} = useStockSearch();
|
||||||
|
|
||||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||||
@@ -107,6 +112,49 @@ export function DashboardContainer() {
|
|||||||
orderBook,
|
orderBook,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canSearch = isKisVerified && !!verifiedCredentials;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 검색 전 API 인증 여부를 확인합니다.
|
||||||
|
* @see features/dashboard/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
|
||||||
|
*/
|
||||||
|
const ensureSearchReady = useCallback(() => {
|
||||||
|
if (canSearch) return true;
|
||||||
|
setSearchError("API 키 검증을 먼저 완료해 주세요.");
|
||||||
|
return false;
|
||||||
|
}, [canSearch, setSearchError]);
|
||||||
|
|
||||||
|
const closeSearchPanel = useCallback(() => {
|
||||||
|
setIsSearchPanelOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openSearchPanel = useCallback(() => {
|
||||||
|
if (!canSearch) return;
|
||||||
|
setIsSearchPanelOpen(true);
|
||||||
|
}, [canSearch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 검색 영역 포커스가 완전히 빠지면 드롭다운(검색결과/히스토리)을 닫습니다.
|
||||||
|
* @see features/dashboard/components/search/StockSearchForm.tsx 입력 포커스 이벤트에서 열림 제어를 함께 사용합니다.
|
||||||
|
*/
|
||||||
|
const handleSearchShellBlur = useCallback(
|
||||||
|
(event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const nextTarget = event.relatedTarget as Node | null;
|
||||||
|
if (nextTarget && searchShellRef.current?.contains(nextTarget)) return;
|
||||||
|
closeSearchPanel();
|
||||||
|
},
|
||||||
|
[closeSearchPanel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchShellKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
closeSearchPanel();
|
||||||
|
(event.target as HTMLElement | null)?.blur?.();
|
||||||
|
},
|
||||||
|
[closeSearchPanel],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||||
|
|
||||||
@@ -142,7 +190,7 @@ export function DashboardContainer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isKisVerified || !verifiedCredentials) {
|
if (!canSearch) {
|
||||||
clearSearch();
|
clearSearch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,52 +206,72 @@ export function DashboardContainer() {
|
|||||||
}, 220);
|
}, 220);
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
|
}, [canSearch, keyword, verifiedCredentials, search, clearSearch]);
|
||||||
|
|
||||||
function handleSearchSubmit(e: React.FormEvent) {
|
/**
|
||||||
e.preventDefault();
|
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
|
||||||
if (!isKisVerified || !verifiedCredentials) {
|
* @see features/dashboard/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
|
||||||
setSearchError("API 키 검증을 먼저 완료해 주세요.");
|
*/
|
||||||
return;
|
const handleSearchSubmit = useCallback(
|
||||||
}
|
(event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||||
search(keyword, verifiedCredentials);
|
search(keyword, verifiedCredentials);
|
||||||
}
|
},
|
||||||
|
[ensureSearchReady, keyword, search, verifiedCredentials],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSelectStock(item: DashboardStockSearchItem) {
|
/**
|
||||||
if (!isKisVerified || !verifiedCredentials) {
|
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
|
||||||
setSearchError("API 키 검증을 먼저 완료해 주세요.");
|
* @see features/dashboard/components/search/StockSearchResults.tsx onSelect 이벤트
|
||||||
return;
|
* @see features/dashboard/components/search/StockSearchHistory.tsx onSelect 이벤트
|
||||||
}
|
*/
|
||||||
|
const handleSelectStock = useCallback(
|
||||||
|
(item: DashboardStockSearchItem) => {
|
||||||
|
if (!ensureSearchReady() || !verifiedCredentials) return;
|
||||||
|
|
||||||
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
|
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
|
||||||
if (selectedStock?.symbol === item.symbol) {
|
if (selectedStock?.symbol === item.symbol) {
|
||||||
setSearchResults([]);
|
clearSearch();
|
||||||
|
closeSearchPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
|
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
|
||||||
skipNextAutoSearchRef.current = true;
|
skipNextAutoSearchRef.current = true;
|
||||||
setKeyword(item.name);
|
setKeyword(item.name);
|
||||||
setSearchResults([]);
|
clearSearch();
|
||||||
|
closeSearchPanel();
|
||||||
|
appendSearchHistory(item);
|
||||||
loadOverview(item.symbol, verifiedCredentials, item.market);
|
loadOverview(item.symbol, verifiedCredentials, item.market);
|
||||||
}
|
},
|
||||||
|
[
|
||||||
|
ensureSearchReady,
|
||||||
|
verifiedCredentials,
|
||||||
|
selectedStock?.symbol,
|
||||||
|
clearSearch,
|
||||||
|
closeSearchPanel,
|
||||||
|
setKeyword,
|
||||||
|
appendSearchHistory,
|
||||||
|
loadOverview,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full flex flex-col">
|
<div className="relative h-full flex flex-col">
|
||||||
{/* ========== AUTH STATUS ========== */}
|
{/* ========== AUTH STATUS ========== */}
|
||||||
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
|
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out dark:border-brand-800/45 dark:bg-brand-900/28">
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold">KIS API 연결 상태:</span>
|
<span className="font-semibold">KIS API 연결 상태:</span>
|
||||||
{isKisVerified ? (
|
{isKisVerified ? (
|
||||||
<span className="text-green-600 font-medium flex items-center">
|
<span className="flex items-center font-medium text-brand-700 dark:text-brand-200">
|
||||||
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
|
<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" ? "실전" : "모의"})
|
||||||
{verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground flex items-center">
|
<span className="text-muted-foreground flex items-center">
|
||||||
<span className="mr-1.5 h-2 w-2 rounded-full bg-gray-300" />
|
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-200 dark:bg-brand-500/60" />
|
||||||
미연결
|
미연결
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -216,8 +284,10 @@ export function DashboardContainer() {
|
|||||||
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
|
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
|
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
|
||||||
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100",
|
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-700/60 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/45",
|
||||||
!isAuthPanelExpanded && isMobileViewport && "ring-2 ring-brand-200",
|
!isAuthPanelExpanded &&
|
||||||
|
isMobileViewport &&
|
||||||
|
"ring-2 ring-brand-200 dark:ring-brand-600/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isAuthPanelExpanded ? (
|
{isAuthPanelExpanded ? (
|
||||||
@@ -237,36 +307,57 @@ export function DashboardContainer() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
|
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
|
||||||
isAuthPanelExpanded
|
isAuthPanelExpanded ? "max-h-[560px] opacity-100" : "max-h-0 opacity-0",
|
||||||
? "max-h-[560px] opacity-100"
|
|
||||||
: "max-h-0 opacity-0",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-t bg-background">
|
<div className="border-t bg-background p-4 dark:border-brand-800/45 dark:bg-brand-900/14">
|
||||||
<KisAuthForm />
|
<KisAuthForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== SEARCH ========== */}
|
{/* ========== SEARCH ========== */}
|
||||||
<div className="flex-none p-4 border-b bg-background/95 backdrop-blur-sm z-30">
|
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||||
<div className="max-w-2xl mx-auto space-y-2 relative">
|
<div
|
||||||
|
ref={searchShellRef}
|
||||||
|
onBlurCapture={handleSearchShellBlur}
|
||||||
|
onKeyDownCapture={handleSearchShellKeyDown}
|
||||||
|
className="relative mx-auto max-w-2xl"
|
||||||
|
>
|
||||||
<StockSearchForm
|
<StockSearchForm
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
onKeywordChange={setKeyword}
|
onKeywordChange={setKeyword}
|
||||||
onSubmit={handleSearchSubmit}
|
onSubmit={handleSearchSubmit}
|
||||||
disabled={!isKisVerified}
|
onInputFocus={openSearchPanel}
|
||||||
|
disabled={!canSearch}
|
||||||
isLoading={isSearching}
|
isLoading={isSearching}
|
||||||
/>
|
/>
|
||||||
{searchResults.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto overflow-x-hidden">
|
{isSearchPanelOpen && canSearch && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
<StockSearchResults
|
<StockSearchResults
|
||||||
items={searchResults}
|
items={searchResults}
|
||||||
onSelect={handleSelectStock}
|
onSelect={handleSelectStock}
|
||||||
selectedSymbol={
|
selectedSymbol={selectedStock?.symbol}
|
||||||
(selectedStock as DashboardStockItem | null)?.symbol
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
) : keyword.trim() ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : searchHistory.length > 0 ? (
|
||||||
|
<StockSearchHistory
|
||||||
|
items={searchHistory}
|
||||||
|
onSelect={handleSelectStock}
|
||||||
|
onRemove={removeSearchHistory}
|
||||||
|
onClear={clearSearchHistory}
|
||||||
|
selectedSymbol={selectedStock?.symbol}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
최근 검색 종목이 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +378,7 @@ export function DashboardContainer() {
|
|||||||
price={currentPrice?.toLocaleString() ?? "0"}
|
price={currentPrice?.toLocaleString() ?? "0"}
|
||||||
change={change?.toLocaleString() ?? "0"}
|
change={change?.toLocaleString() ?? "0"}
|
||||||
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
changeRate={changeRate?.toFixed(2) ?? "0.00"}
|
||||||
high={latestTick ? latestTick.high.toLocaleString() : undefined} // High/Low/Vol only from Tick or Static
|
high={latestTick ? latestTick.high.toLocaleString() : undefined}
|
||||||
low={latestTick ? latestTick.low.toLocaleString() : undefined}
|
low={latestTick ? latestTick.low.toLocaleString() : undefined}
|
||||||
volume={
|
volume={
|
||||||
latestTick
|
latestTick
|
||||||
|
|||||||
@@ -114,10 +114,10 @@ export function KisAuthForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>KIS API 키 연결</CardTitle>
|
<CardTitle className="text-foreground dark:text-brand-50">KIS API 키 연결</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-muted-foreground dark:text-brand-100/80">
|
||||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요.
|
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요.
|
||||||
검증에 성공해야 시세 조회가 동작합니다.
|
검증에 성공해야 시세 조회가 동작합니다.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -127,7 +127,7 @@ export function KisAuthForm() {
|
|||||||
{/* ========== CREDENTIAL INPUTS ========== */}
|
{/* ========== CREDENTIAL INPUTS ========== */}
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<label className="mb-1 block text-xs text-muted-foreground dark:text-brand-100/72">
|
||||||
거래 모드
|
거래 모드
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -135,10 +135,11 @@ export function KisAuthForm() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1",
|
"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"
|
kisTradingEnvInput === "real"
|
||||||
? "bg-brand-600 hover:bg-brand-700"
|
? "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("real")}
|
onClick={() => setKisTradingEnvInput("real")}
|
||||||
>
|
>
|
||||||
@@ -148,10 +149,11 @@ export function KisAuthForm() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1",
|
"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"
|
kisTradingEnvInput === "mock"
|
||||||
? "bg-brand-600 hover:bg-brand-700"
|
? "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")}
|
onClick={() => setKisTradingEnvInput("mock")}
|
||||||
>
|
>
|
||||||
@@ -166,6 +168,7 @@ export function KisAuthForm() {
|
|||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
||||||
value={kisAppKeyInput}
|
value={kisAppKeyInput}
|
||||||
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
||||||
placeholder="App Key 입력"
|
placeholder="App Key 입력"
|
||||||
@@ -179,6 +182,7 @@ export function KisAuthForm() {
|
|||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
||||||
value={kisAppSecretInput}
|
value={kisAppSecretInput}
|
||||||
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
||||||
placeholder="App Secret 입력"
|
placeholder="App Secret 입력"
|
||||||
@@ -205,14 +209,14 @@ export function KisAuthForm() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRevoke}
|
onClick={handleRevoke}
|
||||||
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
|
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
|
||||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
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 ? "해제 중..." : "연결 끊기"}
|
{isRevoking ? "해제 중..." : "연결 끊기"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isKisVerified ? (
|
{isKisVerified ? (
|
||||||
<span className="flex items-center text-sm font-medium text-green-600">
|
<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-green-500 ring-2 ring-green-100" />
|
<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" ? "실전" : "모의"})
|
연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,7 +227,9 @@ export function KisAuthForm() {
|
|||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
|
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
|
||||||
)}
|
)}
|
||||||
{statusMessage && <div className="text-sm text-blue-600">{statusMessage}</div>}
|
{statusMessage && (
|
||||||
|
<div className="text-sm text-brand-700 dark:text-brand-200">{statusMessage}</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type Time,
|
type Time,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
|
||||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
@@ -34,10 +35,64 @@ import {
|
|||||||
} from "./chart-utils";
|
} from "./chart-utils";
|
||||||
|
|
||||||
const UP_COLOR = "#ef4444";
|
const UP_COLOR = "#ef4444";
|
||||||
const DOWN_COLOR = "#2563eb";
|
|
||||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||||
|
|
||||||
|
interface ChartPalette {
|
||||||
|
backgroundColor: string;
|
||||||
|
downColor: string;
|
||||||
|
volumeDownColor: string;
|
||||||
|
textColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
gridColor: string;
|
||||||
|
crosshairColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CHART_PALETTE: ChartPalette = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
downColor: "#2563eb",
|
||||||
|
volumeDownColor: "rgba(37, 99, 235, 0.45)",
|
||||||
|
textColor: "#6d28d9",
|
||||||
|
borderColor: "#e9d5ff",
|
||||||
|
gridColor: "#f3e8ff",
|
||||||
|
crosshairColor: "#c084fc",
|
||||||
|
};
|
||||||
|
|
||||||
|
function readCssVar(name: string, fallback: string) {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
|
||||||
|
const isDark = themeMode === "dark";
|
||||||
|
const backgroundVar = isDark
|
||||||
|
? "--brand-chart-background-dark"
|
||||||
|
: "--brand-chart-background-light";
|
||||||
|
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
|
||||||
|
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
|
||||||
|
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
|
||||||
|
const crosshairVar = isDark
|
||||||
|
? "--brand-chart-crosshair-dark"
|
||||||
|
: "--brand-chart-crosshair-light";
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
|
||||||
|
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
|
||||||
|
volumeDownColor: readCssVar(
|
||||||
|
"--brand-chart-volume-down",
|
||||||
|
DEFAULT_CHART_PALETTE.volumeDownColor,
|
||||||
|
),
|
||||||
|
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
|
||||||
|
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
|
||||||
|
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
|
||||||
|
crosshairColor: readCssVar(
|
||||||
|
crosshairVar,
|
||||||
|
DEFAULT_CHART_PALETTE.crosshairColor,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const MINUTE_TIMEFRAMES: Array<{
|
const MINUTE_TIMEFRAMES: Array<{
|
||||||
value: DashboardChartTimeframe;
|
value: DashboardChartTimeframe;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -73,6 +128,7 @@ export function StockLineChart({
|
|||||||
credentials,
|
credentials,
|
||||||
latestTick,
|
latestTick,
|
||||||
}: StockLineChartProps) {
|
}: StockLineChartProps) {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
|
||||||
@@ -87,6 +143,18 @@ export function StockLineChart({
|
|||||||
const [isChartReady, setIsChartReady] = useState(false);
|
const [isChartReady, setIsChartReady] = useState(false);
|
||||||
const lastRealtimeKeyRef = useRef<string>("");
|
const lastRealtimeKeyRef = useRef<string>("");
|
||||||
const lastRealtimeAppliedAtRef = useRef(0);
|
const lastRealtimeAppliedAtRef = useRef(0);
|
||||||
|
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
|
||||||
|
const renderableBarsRef = useRef<ChartBar[]>([]);
|
||||||
|
|
||||||
|
const activeThemeMode: "light" | "dark" =
|
||||||
|
resolvedTheme === "dark"
|
||||||
|
? "dark"
|
||||||
|
: resolvedTheme === "light"
|
||||||
|
? "light"
|
||||||
|
: typeof document !== "undefined" &&
|
||||||
|
document.documentElement.classList.contains("dark")
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
|
||||||
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
|
||||||
const loadingMoreRef = useRef(false);
|
const loadingMoreRef = useRef(false);
|
||||||
@@ -125,6 +193,10 @@ export function StockLineChart({
|
|||||||
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
return [...dedup.values()].sort((a, b) => a.time - b.time);
|
||||||
}, [bars]);
|
}, [bars]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
renderableBarsRef.current = renderableBars;
|
||||||
|
}, [renderableBars]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
|
||||||
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
|
* @see features/dashboard/components/chart/StockLineChart.tsx renderableBars useMemo
|
||||||
@@ -152,7 +224,7 @@ export function StockLineChart({
|
|||||||
color:
|
color:
|
||||||
bar.close >= bar.open
|
bar.close >= bar.open
|
||||||
? "rgba(239,68,68,0.45)"
|
? "rgba(239,68,68,0.45)"
|
||||||
: "rgba(37,99,235,0.45)",
|
: chartPaletteRef.current.volumeDownColor,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -206,12 +278,16 @@ export function StockLineChart({
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container || chartRef.current) return;
|
if (!container || chartRef.current) return;
|
||||||
|
|
||||||
|
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
|
||||||
|
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||||
|
chartPaletteRef.current = palette;
|
||||||
|
|
||||||
const chart = createChart(container, {
|
const chart = createChart(container, {
|
||||||
width: Math.max(container.clientWidth, 320),
|
width: Math.max(container.clientWidth, 320),
|
||||||
height: Math.max(container.clientHeight, 340),
|
height: Math.max(container.clientHeight, 340),
|
||||||
layout: {
|
layout: {
|
||||||
background: { type: ColorType.Solid, color: "#ffffff" },
|
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||||
textColor: "#475569",
|
textColor: palette.textColor,
|
||||||
attributionLogo: true,
|
attributionLogo: true,
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
@@ -219,22 +295,22 @@ export function StockLineChart({
|
|||||||
timeFormatter: formatKstCrosshairTime,
|
timeFormatter: formatKstCrosshairTime,
|
||||||
},
|
},
|
||||||
rightPriceScale: {
|
rightPriceScale: {
|
||||||
borderColor: "#e2e8f0",
|
borderColor: palette.borderColor,
|
||||||
scaleMargins: {
|
scaleMargins: {
|
||||||
top: 0.08,
|
top: 0.08,
|
||||||
bottom: 0.24,
|
bottom: 0.24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
vertLines: { color: "#edf1f5" },
|
vertLines: { color: palette.gridColor },
|
||||||
horzLines: { color: "#edf1f5" },
|
horzLines: { color: palette.gridColor },
|
||||||
},
|
},
|
||||||
crosshair: {
|
crosshair: {
|
||||||
vertLine: { color: "#94a3b8", width: 1, style: 2 },
|
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||||
horzLine: { color: "#94a3b8", width: 1, style: 2 },
|
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||||
},
|
},
|
||||||
timeScale: {
|
timeScale: {
|
||||||
borderColor: "#e2e8f0",
|
borderColor: palette.borderColor,
|
||||||
timeVisible: true,
|
timeVisible: true,
|
||||||
secondsVisible: false,
|
secondsVisible: false,
|
||||||
rightOffset: 2,
|
rightOffset: 2,
|
||||||
@@ -253,11 +329,11 @@ export function StockLineChart({
|
|||||||
|
|
||||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||||
upColor: UP_COLOR,
|
upColor: UP_COLOR,
|
||||||
downColor: DOWN_COLOR,
|
downColor: palette.downColor,
|
||||||
wickUpColor: UP_COLOR,
|
wickUpColor: UP_COLOR,
|
||||||
wickDownColor: DOWN_COLOR,
|
wickDownColor: palette.downColor,
|
||||||
borderUpColor: UP_COLOR,
|
borderUpColor: UP_COLOR,
|
||||||
borderDownColor: DOWN_COLOR,
|
borderDownColor: palette.downColor,
|
||||||
priceLineVisible: true,
|
priceLineVisible: true,
|
||||||
lastValueVisible: true,
|
lastValueVisible: true,
|
||||||
});
|
});
|
||||||
@@ -318,7 +394,41 @@ export function StockLineChart({
|
|||||||
volumeSeriesRef.current = null;
|
volumeSeriesRef.current = null;
|
||||||
setIsChartReady(false);
|
setIsChartReady(false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [activeThemeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
const candleSeries = candleSeriesRef.current;
|
||||||
|
if (!chart || !candleSeries) return;
|
||||||
|
|
||||||
|
const palette = getChartPaletteFromCssVars(activeThemeMode);
|
||||||
|
chartPaletteRef.current = palette;
|
||||||
|
|
||||||
|
chart.applyOptions({
|
||||||
|
layout: {
|
||||||
|
background: { type: ColorType.Solid, color: palette.backgroundColor },
|
||||||
|
textColor: palette.textColor,
|
||||||
|
},
|
||||||
|
rightPriceScale: { borderColor: palette.borderColor },
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: palette.gridColor },
|
||||||
|
horzLines: { color: palette.gridColor },
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||||
|
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
|
||||||
|
},
|
||||||
|
timeScale: { borderColor: palette.borderColor },
|
||||||
|
});
|
||||||
|
|
||||||
|
candleSeries.applyOptions({
|
||||||
|
downColor: palette.downColor,
|
||||||
|
wickDownColor: palette.downColor,
|
||||||
|
borderDownColor: palette.downColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSeriesData(renderableBarsRef.current);
|
||||||
|
}, [activeThemeMode, setSeriesData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (symbol && credentials) return;
|
if (symbol && credentials) return;
|
||||||
@@ -344,25 +454,33 @@ export function StockLineChart({
|
|||||||
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
|
||||||
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
|
||||||
|
|
||||||
// 분봉은 기본 2페이지를 붙여서 "당일만 보이는" 느낌을 줄입니다.
|
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
|
||||||
if (
|
if (
|
||||||
isMinuteTimeframe(timeframe) &&
|
isMinuteTimeframe(timeframe) &&
|
||||||
firstPage.hasMore &&
|
firstPage.hasMore &&
|
||||||
firstPage.nextCursor
|
firstPage.nextCursor
|
||||||
) {
|
) {
|
||||||
|
let minuteCursor: string | null = firstPage.nextCursor;
|
||||||
|
let extraPageCount = 0;
|
||||||
|
|
||||||
|
while (minuteCursor && extraPageCount < 2) {
|
||||||
try {
|
try {
|
||||||
const secondPage = await fetchStockChart(
|
const olderPage = await fetchStockChart(
|
||||||
symbol,
|
symbol,
|
||||||
timeframe,
|
timeframe,
|
||||||
credentials,
|
credentials,
|
||||||
firstPage.nextCursor,
|
minuteCursor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const olderBars = normalizeCandles(secondPage.candles, timeframe);
|
const olderBars = normalizeCandles(olderPage.candles, timeframe);
|
||||||
mergedBars = mergeBars(olderBars, mergedBars);
|
mergedBars = mergeBars(olderBars, mergedBars);
|
||||||
resolvedNextCursor = secondPage.hasMore ? secondPage.nextCursor : null;
|
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||||
|
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
|
||||||
|
extraPageCount += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// 2페이지 실패는 치명적이지 않으므로 1페이지 데이터는 유지합니다.
|
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
|
||||||
|
minuteCursor = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,9 +597,9 @@ export function StockLineChart({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[340px] flex-col bg-white">
|
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
|
||||||
{/* ========== CHART TOOLBAR ========== */}
|
{/* ========== CHART TOOLBAR ========== */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -491,9 +609,9 @@ export function StockLineChart({
|
|||||||
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||||
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
|
||||||
"bg-brand-100 font-semibold text-brand-700",
|
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
|
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
|
||||||
@@ -502,7 +620,7 @@ export function StockLineChart({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isMinuteDropdownOpen && (
|
{isMinuteDropdownOpen && (
|
||||||
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
|
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
|
||||||
{MINUTE_TIMEFRAMES.map((item) => (
|
{MINUTE_TIMEFRAMES.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -512,9 +630,9 @@ export function StockLineChart({
|
|||||||
setIsMinuteDropdownOpen(false);
|
setIsMinuteDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
|
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
|
||||||
timeframe === item.value &&
|
timeframe === item.value &&
|
||||||
"bg-brand-50 font-semibold text-brand-700",
|
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -530,9 +648,9 @@ export function StockLineChart({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTimeframe(item.value)}
|
onClick={() => setTimeframe(item.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
|
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
|
||||||
timeframe === item.value &&
|
timeframe === item.value &&
|
||||||
"bg-brand-100 font-semibold text-brand-700",
|
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -540,16 +658,20 @@ export function StockLineChart({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
<span className="ml-2 text-[11px] text-muted-foreground">
|
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
|
||||||
과거 데이터 로딩 중...
|
과거 데이터 로딩 중...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-slate-600 sm:text-xs">
|
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
|
||||||
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
|
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
|
||||||
{formatPrice(latest?.low ?? 0)} C{" "}
|
{formatPrice(latest?.low ?? 0)} C{" "}
|
||||||
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +682,7 @@ export function StockLineChart({
|
|||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
|
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground dark:bg-background/90 dark:text-brand-100/80">
|
||||||
{statusMessage}
|
{statusMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function StockOverviewCard({
|
|||||||
<CardDescription className="mt-1 flex items-center gap-1.5">
|
<CardDescription className="mt-1 flex items-center gap-1.5">
|
||||||
<span>{effectivePriceSourceLabel}</span>
|
<span>{effectivePriceSourceLabel}</span>
|
||||||
{isRealtimeConnected && (
|
{isRealtimeConnected && (
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
|
<span className="inline-flex items-center gap-1 rounded bg-brand-100 px-1.5 py-0.5 text-xs font-medium text-brand-700">
|
||||||
<Activity className="h-3 w-3" />
|
<Activity className="h-3 w-3" />
|
||||||
실시간
|
실시간
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function StockPriceBadge({
|
|||||||
}: StockPriceBadgeProps) {
|
}: StockPriceBadgeProps) {
|
||||||
const isPositive = change >= 0;
|
const isPositive = change >= 0;
|
||||||
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
|
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
|
||||||
const changeColor = isPositive ? "text-red-500" : "text-blue-500";
|
const changeColor = isPositive ? "text-red-500" : "text-brand-600";
|
||||||
const changeSign = isPositive ? "+" : "";
|
const changeSign = isPositive ? "+" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,18 +27,18 @@ export function StockHeader({
|
|||||||
const colorClass = isRise
|
const colorClass = isRise
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: isFall
|
: isFall
|
||||||
? "text-blue-500"
|
? "text-blue-600 dark:text-blue-400"
|
||||||
: "text-foreground";
|
: "text-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 sm:px-4 sm:py-3">
|
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
|
||||||
{/* ========== STOCK SUMMARY ========== */}
|
{/* ========== STOCK SUMMARY ========== */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="truncate text-lg font-bold leading-tight sm:text-xl">
|
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
|
||||||
{stock.name}
|
{stock.name}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground sm:text-sm">
|
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
|
||||||
{stock.symbol}/{stock.market}
|
{stock.symbol}/{stock.market}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,16 +53,16 @@ export function StockHeader({
|
|||||||
|
|
||||||
{/* ========== STATS ========== */}
|
{/* ========== STATS ========== */}
|
||||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
|
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||||
<p className="text-[11px] text-muted-foreground">고가</p>
|
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">고가</p>
|
||||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
<p className="font-medium text-red-500">{high || "--"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||||
<p className="text-[11px] text-muted-foreground">저가</p>
|
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">저가</p>
|
||||||
<p className="font-medium text-blue-500">{low || "--"}</p>
|
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
|
||||||
<p className="text-[11px] text-muted-foreground">거래량(24H)</p>
|
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">거래량(24H)</p>
|
||||||
<p className="font-medium">{volume || "--"}</p>
|
<p className="font-medium">{volume || "--"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,15 +72,15 @@ export function StockHeader({
|
|||||||
{/* ========== DESKTOP STATS ========== */}
|
{/* ========== DESKTOP STATS ========== */}
|
||||||
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
|
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-muted-foreground text-xs">고가</span>
|
<span className="text-muted-foreground text-xs dark:text-brand-100/70">고가</span>
|
||||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-muted-foreground text-xs">저가</span>
|
<span className="text-muted-foreground text-xs dark:text-brand-100/70">저가</span>
|
||||||
<span className="font-medium text-blue-500">{low || "--"}</span>
|
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-muted-foreground text-xs">거래량(24H)</span>
|
<span className="text-muted-foreground text-xs dark:text-brand-100/70">거래량(24H)</span>
|
||||||
<span className="font-medium">{volume || "--"}</span>
|
<span className="font-medium">{volume || "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col bg-background",
|
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
|
||||||
// Mobile: Scrollable page height
|
// Mobile: Scrollable page height
|
||||||
"min-h-[calc(100vh-64px)]",
|
"min-h-[calc(100vh-64px)]",
|
||||||
// Desktop: Fixed height, no window scroll
|
// Desktop: Fixed height, no window scroll
|
||||||
@@ -28,7 +28,7 @@ export function DashboardLayout({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 1. Header Area */}
|
{/* 1. Header Area */}
|
||||||
<div className="flex-none border-b border-border bg-background">
|
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function DashboardLayout({
|
|||||||
{/* Left Column: Chart & Info */}
|
{/* Left Column: Chart & Info */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border-border",
|
"flex flex-col border-border dark:border-brand-800/45",
|
||||||
// Mobile: Fixed height for chart to ensure visibility
|
// Mobile: Fixed height for chart to ensure visibility
|
||||||
"h-[320px] flex-none border-b sm:h-[360px]",
|
"h-[320px] flex-none border-b sm:h-[360px]",
|
||||||
// Desktop: Fill remaining space, remove bottom border, add right border
|
// Desktop: Fill remaining space, remove bottom border, add right border
|
||||||
@@ -57,9 +57,9 @@ export function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Order Book & Order Form */}
|
{/* Right Column: Order Book & Order Form */}
|
||||||
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] xl:pr-2 2xl:w-[500px]">
|
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
|
||||||
{/* Top: Order Book (Hoga) */}
|
{/* Top: Order Book (Hoga) */}
|
||||||
<div className="h-[390px] flex-none overflow-hidden border-t border-border sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
|
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
|
||||||
{orderBook}
|
{orderBook}
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom: Order Form */}
|
{/* Bottom: Order Form */}
|
||||||
|
|||||||
@@ -1,71 +1,70 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type {
|
|
||||||
DashboardStockItem,
|
|
||||||
DashboardOrderSide,
|
|
||||||
} from "@/features/dashboard/types/dashboard.types";
|
|
||||||
import { useOrder } from "@/features/dashboard/hooks/useOrder";
|
import { useOrder } from "@/features/dashboard/hooks/useOrder";
|
||||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
import { Loader2 } from "lucide-react";
|
import type {
|
||||||
|
DashboardOrderSide,
|
||||||
|
DashboardStockItem,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
interface OrderFormProps {
|
interface OrderFormProps {
|
||||||
stock?: DashboardStockItem;
|
stock?: DashboardStockItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
|
||||||
|
* @see features/dashboard/hooks/useOrder.ts placeOrder - 주문 API 호출
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx OrderForm - 우측 주문 패널 렌더링
|
||||||
|
*/
|
||||||
export function OrderForm({ stock }: OrderFormProps) {
|
export function OrderForm({ stock }: OrderFormProps) {
|
||||||
const verifiedCredentials = useKisRuntimeStore(
|
const verifiedCredentials = useKisRuntimeStore(
|
||||||
(state) => state.verifiedCredentials,
|
(state) => state.verifiedCredentials,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { placeOrder, isLoading, error } = useOrder();
|
const { placeOrder, isLoading, error } = useOrder();
|
||||||
|
|
||||||
// Form State
|
// ========== FORM STATE ==========
|
||||||
// Initial price set from stock current price if available, relying on component remount (key) for updates
|
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
|
||||||
const [price, setPrice] = useState<string>(
|
|
||||||
stock?.currentPrice.toString() || "",
|
|
||||||
);
|
|
||||||
const [quantity, setQuantity] = useState<string>("");
|
const [quantity, setQuantity] = useState<string>("");
|
||||||
const [activeTab, setActiveTab] = useState("buy");
|
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
|
||||||
|
|
||||||
|
// ========== ORDER HANDLER ==========
|
||||||
const handleOrder = async (side: DashboardOrderSide) => {
|
const handleOrder = async (side: DashboardOrderSide) => {
|
||||||
if (!stock || !verifiedCredentials) return;
|
if (!stock || !verifiedCredentials) return;
|
||||||
|
|
||||||
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
const priceNum = parseInt(price.replace(/,/g, ""), 10);
|
||||||
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
|
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
|
||||||
|
|
||||||
if (isNaN(priceNum) || priceNum <= 0) {
|
if (Number.isNaN(priceNum) || priceNum <= 0) {
|
||||||
alert("가격을 올바르게 입력해주세요.");
|
alert("가격을 올바르게 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isNaN(qtyNum) || qtyNum <= 0) {
|
if (Number.isNaN(qtyNum) || qtyNum <= 0) {
|
||||||
alert("수량을 올바르게 입력해주세요.");
|
alert("수량을 올바르게 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verifiedCredentials.accountNo) {
|
if (!verifiedCredentials.accountNo) {
|
||||||
alert(
|
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
|
||||||
"계좌번호가 설정되지 않았습니다. 설정에서 계좌번호를 입력해주세요.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await placeOrder(
|
const response = await placeOrder(
|
||||||
{
|
{
|
||||||
symbol: stock.symbol,
|
symbol: stock.symbol,
|
||||||
side: side,
|
side,
|
||||||
orderType: "limit", // 지정가 고정
|
orderType: "limit",
|
||||||
price: priceNum,
|
price: priceNum,
|
||||||
quantity: qtyNum,
|
quantity: qtyNum,
|
||||||
accountNo: verifiedCredentials.accountNo,
|
accountNo: verifiedCredentials.accountNo,
|
||||||
accountProductCode: "01", // Default to '01' (위탁)
|
accountProductCode: "01",
|
||||||
},
|
},
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.orderNo) {
|
if (response?.orderNo) {
|
||||||
alert(`주문 전송 완료! 주문번호: ${response.orderNo}`);
|
alert(`주문 전송 완료: ${response.orderNo}`);
|
||||||
setQuantity("");
|
setQuantity("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,34 +74,36 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
parseInt(quantity.replace(/,/g, "") || "0", 10);
|
||||||
|
|
||||||
const setPercent = (pct: string) => {
|
const setPercent = (pct: string) => {
|
||||||
// Placeholder logic for percent click
|
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
|
||||||
console.log("Percent clicked:", pct);
|
console.log("Percent clicked:", pct);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMarketDataAvailable = !!stock;
|
const isMarketDataAvailable = Boolean(stock);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full border-l border-border bg-background p-3 sm:p-4">
|
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
|
||||||
className="w-full h-full flex flex-col"
|
className="flex h-full w-full flex-col"
|
||||||
>
|
>
|
||||||
<TabsList className="mb-3 grid w-full grid-cols-2 sm:mb-4">
|
{/* ========== ORDER SIDE TABS ========== */}
|
||||||
|
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="buy"
|
value="buy"
|
||||||
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
|
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
|
||||||
>
|
>
|
||||||
매수
|
매수
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="sell"
|
value="sell"
|
||||||
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white transition-colors"
|
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
|
||||||
>
|
>
|
||||||
매도
|
매도
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ========== BUY TAB ========== */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="buy"
|
value="buy"
|
||||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||||
@@ -115,21 +116,20 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
setQuantity={setQuantity}
|
setQuantity={setQuantity}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
disabled={!isMarketDataAvailable}
|
disabled={!isMarketDataAvailable}
|
||||||
hasError={!!error}
|
hasError={Boolean(error)}
|
||||||
errorMessage={error}
|
errorMessage={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto h-11 w-full bg-red-600 text-base hover:bg-red-700 sm:h-12 sm:text-lg"
|
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
|
||||||
disabled={isLoading || !isMarketDataAvailable}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("buy")}
|
onClick={() => handleOrder("buy")}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매수하기"}
|
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
|
||||||
</Button>
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ========== SELL TAB ========== */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="sell"
|
value="sell"
|
||||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
||||||
@@ -142,18 +142,16 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
setQuantity={setQuantity}
|
setQuantity={setQuantity}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
disabled={!isMarketDataAvailable}
|
disabled={!isMarketDataAvailable}
|
||||||
hasError={!!error}
|
hasError={Boolean(error)}
|
||||||
errorMessage={error}
|
errorMessage={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto h-11 w-full bg-blue-600 text-base hover:bg-blue-700 sm:h-12 sm:text-lg"
|
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
|
||||||
disabled={isLoading || !isMarketDataAvailable}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("sell")}
|
onClick={() => handleOrder("sell")}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매도하기"}
|
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
|
||||||
</Button>
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -161,6 +159,10 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
|
||||||
|
* @see features/dashboard/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
|
||||||
|
*/
|
||||||
function OrderInputs({
|
function OrderInputs({
|
||||||
type,
|
type,
|
||||||
price,
|
price,
|
||||||
@@ -190,7 +192,7 @@ function OrderInputs({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasError && (
|
{hasError && (
|
||||||
<div className="p-2 bg-destructive/10 text-destructive text-xs rounded break-keep">
|
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,27 +202,29 @@ function OrderInputs({
|
|||||||
{type === "buy" ? "매수가격" : "매도가격"}
|
{type === "buy" ? "매수가격" : "매도가격"}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3 text-right font-mono"
|
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 items-center gap-2">
|
||||||
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3 text-right font-mono"
|
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 items-center gap-2">
|
||||||
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3 text-right font-mono bg-muted/50"
|
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
|
||||||
value={totalPrice.toLocaleString()}
|
value={totalPrice.toLocaleString()}
|
||||||
readOnly
|
readOnly
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -230,9 +234,13 @@ function OrderInputs({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
|
||||||
|
* @see features/dashboard/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
|
||||||
|
*/
|
||||||
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-2 mt-2">
|
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||||
{["10%", "25%", "50%", "100%"].map((pct) => (
|
{["10%", "25%", "50%", "100%"].map((pct) => (
|
||||||
<Button
|
<Button
|
||||||
key={pct}
|
key={pct}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function AnimatedQuantity({
|
|||||||
transition={{ duration: 1 }}
|
transition={{ duration: 1 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 z-0 rounded-sm",
|
"absolute inset-0 z-0 rounded-sm",
|
||||||
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
|
flash === "up" ? "bg-red-200/50" : "bg-brand-200/50",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -78,7 +78,7 @@ export function AnimatedQuantity({
|
|||||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
|
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
|
||||||
diff > 0 ? "text-red-500" : "text-blue-500",
|
diff > 0 ? "text-red-500" : "text-brand-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||||
|
|||||||
@@ -145,10 +145,10 @@ export function OrderBook({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
|
||||||
<Tabs defaultValue="normal" className="h-full min-h-0">
|
<Tabs defaultValue="normal" className="h-full min-h-0">
|
||||||
{/* 탭 헤더 */}
|
{/* 탭 헤더 */}
|
||||||
<div className="border-b px-2 pt-2">
|
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
|
||||||
<TabsList variant="line" className="w-full justify-start">
|
<TabsList variant="line" className="w-full justify-start">
|
||||||
<TabsTrigger value="normal" className="px-3">
|
<TabsTrigger value="normal" className="px-3">
|
||||||
일반호가
|
일반호가
|
||||||
@@ -164,10 +164,10 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* ── 일반호가 탭 ── */}
|
{/* ── 일반호가 탭 ── */}
|
||||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||||
<div className="block h-full min-h-0 border-t xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
||||||
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
||||||
{/* 호가 테이블 */}
|
{/* 호가 테이블 */}
|
||||||
<div className="min-h-0 xl:border-r">
|
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
|
||||||
<BookHeader />
|
<BookHeader />
|
||||||
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
||||||
{/* 매도호가 */}
|
{/* 매도호가 */}
|
||||||
@@ -175,7 +175,7 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* 중앙 바: 현재 체결가 */}
|
{/* 중앙 바: 현재 체결가 */}
|
||||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
||||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
|
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
@@ -192,14 +192,14 @@ export function OrderBook({
|
|||||||
"text-[10px] font-medium",
|
"text-[10px] font-medium",
|
||||||
latestPrice >= basePrice
|
latestPrice >= basePrice
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: "text-blue-500",
|
: "text-blue-600 dark:text-blue-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fmtPct(pctChange(latestPrice, basePrice))}
|
{fmtPct(pctChange(latestPrice, basePrice))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
|
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
|
||||||
{totalBid > 0 ? fmt(totalBid) : ""}
|
{totalBid > 0 ? fmt(totalBid) : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +231,7 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* ── 누적호가 탭 ── */}
|
{/* ── 누적호가 탭 ── */}
|
||||||
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
<TabsContent value="cumulative" className="min-h-0 flex-1">
|
||||||
<ScrollArea className="h-full border-t">
|
<ScrollArea className="h-full border-t dark:border-brand-800/45">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
|
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
|
||||||
<span>매도누적</span>
|
<span>매도누적</span>
|
||||||
@@ -245,7 +245,7 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* ── 호가주문 탭 ── */}
|
{/* ── 호가주문 탭 ── */}
|
||||||
<TabsContent value="order" className="min-h-0 flex-1">
|
<TabsContent value="order" className="min-h-0 flex-1">
|
||||||
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
|
||||||
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
호가주문 탭은 주문 입력 패널과 연동해 확장할 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -259,7 +259,7 @@ export function OrderBook({
|
|||||||
/** 호가 표 헤더 */
|
/** 호가 표 헤더 */
|
||||||
function BookHeader() {
|
function BookHeader() {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground">
|
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||||
<div className="flex items-center justify-end px-2">매도잔량</div>
|
<div className="flex items-center justify-end px-2">매도잔량</div>
|
||||||
<div className="flex items-center justify-center border-x">호가</div>
|
<div className="flex items-center justify-center border-x">호가</div>
|
||||||
<div className="flex items-center justify-start px-2">매수잔량</div>
|
<div className="flex items-center justify-start px-2">매수잔량</div>
|
||||||
@@ -280,7 +280,13 @@ function BookSideRows({
|
|||||||
const isAsk = side === "ask";
|
const isAsk = side === "ask";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
isAsk
|
||||||
|
? "bg-red-50/20 dark:bg-red-950/18"
|
||||||
|
: "bg-blue-50/55 dark:bg-blue-950/22",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const ratio =
|
const ratio =
|
||||||
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
|
||||||
@@ -289,9 +295,9 @@ function BookSideRows({
|
|||||||
<div
|
<div
|
||||||
key={`${side}-${row.price}-${i}`}
|
key={`${side}-${row.price}-${i}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs",
|
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
|
||||||
row.isHighlighted &&
|
row.isHighlighted &&
|
||||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
|
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 매도잔량 (좌측) */}
|
{/* 매도잔량 (좌측) */}
|
||||||
@@ -314,10 +320,10 @@ function BookSideRows({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
|
||||||
row.isHighlighted &&
|
row.isHighlighted &&
|
||||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-900/25",
|
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
|
<span className={isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"}>
|
||||||
{row.price > 0 ? fmt(row.price) : "-"}
|
{row.price > 0 ? fmt(row.price) : "-"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -326,7 +332,7 @@ function BookSideRows({
|
|||||||
row.changePercent !== null
|
row.changePercent !== null
|
||||||
? row.changePercent >= 0
|
? row.changePercent >= 0
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: "text-blue-500"
|
: "text-blue-600 dark:text-blue-400"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -372,7 +378,7 @@ function SummaryPanel({
|
|||||||
totalBid: number;
|
totalBid: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 border-l bg-muted/15 p-2 text-[11px]">
|
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||||
<Row
|
<Row
|
||||||
label="실시간"
|
label="실시간"
|
||||||
value={orderBook ? "연결됨" : "끊김"}
|
value={orderBook ? "연결됨" : "끊김"}
|
||||||
@@ -444,13 +450,13 @@ function Row({
|
|||||||
tone?: "ask" | "bid";
|
tone?: "ask" | "bid";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1">
|
<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">{label}</span>
|
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">{label}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 font-medium tabular-nums",
|
"shrink-0 font-medium tabular-nums",
|
||||||
tone === "ask" && "text-red-600",
|
tone === "ask" && "text-red-600",
|
||||||
tone === "bid" && "text-blue-600",
|
tone === "bid" && "text-blue-600 dark:text-blue-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
@@ -466,7 +472,9 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-1 z-0 rounded-sm",
|
"absolute inset-y-1 z-0 rounded-sm",
|
||||||
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
|
side === "ask"
|
||||||
|
? "right-1 bg-red-200/50 dark:bg-red-800/40"
|
||||||
|
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
|
||||||
)}
|
)}
|
||||||
style={{ width: `${ratio}%` }}
|
style={{ width: `${ratio}%` }}
|
||||||
/>
|
/>
|
||||||
@@ -476,8 +484,8 @@ function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
|
|||||||
/** 체결 목록 (Trade Tape) */
|
/** 체결 목록 (Trade Tape) */
|
||||||
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="border-t bg-background">
|
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
|
||||||
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
|
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
|
||||||
<div className="flex items-center">체결시각</div>
|
<div className="flex items-center">체결시각</div>
|
||||||
<div className="flex items-center justify-end">체결가</div>
|
<div className="flex items-center justify-end">체결가</div>
|
||||||
<div className="flex items-center justify-end">체결량</div>
|
<div className="flex items-center justify-end">체결량</div>
|
||||||
@@ -486,14 +494,14 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
<ScrollArea className="h-[162px]">
|
<ScrollArea className="h-[162px]">
|
||||||
<div>
|
<div>
|
||||||
{ticks.length === 0 && (
|
{ticks.length === 0 && (
|
||||||
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground">
|
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
|
||||||
체결 데이터가 아직 없습니다.
|
체결 데이터가 아직 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ticks.map((t, i) => (
|
{ticks.map((t, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${t.tickTime}-${t.price}-${i}`}
|
key={`${t.tickTime}-${t.price}-${i}`}
|
||||||
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
|
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
|
||||||
>
|
>
|
||||||
<div className="flex items-center tabular-nums">
|
<div className="flex items-center tabular-nums">
|
||||||
{fmtTime(t.tickTime)}
|
{fmtTime(t.tickTime)}
|
||||||
@@ -501,7 +509,7 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
<div className="flex items-center justify-end tabular-nums text-red-600">
|
||||||
{fmt(t.price)}
|
{fmt(t.price)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end tabular-nums text-blue-600">
|
<div className="flex items-center justify-end tabular-nums text-blue-600 dark:text-blue-400">
|
||||||
{fmt(t.tradeVolume)}
|
{fmt(t.tradeVolume)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end tabular-nums">
|
<div className="flex items-center justify-end tabular-nums">
|
||||||
@@ -537,13 +545,13 @@ function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
|
|||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs"
|
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
|
||||||
>
|
>
|
||||||
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
|
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
|
||||||
<span className="text-center font-medium tabular-nums">
|
<span className="text-center font-medium tabular-nums">
|
||||||
{fmt(r.price)}
|
{fmt(r.price)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right tabular-nums text-blue-600">
|
<span className="text-right tabular-nums text-blue-600 dark:text-blue-400">
|
||||||
{fmt(r.bidAcc)}
|
{fmt(r.bidAcc)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,45 @@
|
|||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface StockSearchFormProps {
|
interface StockSearchFormProps {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
onKeywordChange: (value: string) => void;
|
onKeywordChange: (value: string) => void;
|
||||||
onSubmit: (e: FormEvent) => void;
|
onSubmit: (event: FormEvent) => void;
|
||||||
|
onInputFocus?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 종목 검색 입력/제출 폼을 렌더링합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 키워드 입력 이벤트를 전달합니다.
|
||||||
|
*/
|
||||||
export function StockSearchForm({
|
export function StockSearchForm({
|
||||||
keyword,
|
keyword,
|
||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onInputFocus,
|
||||||
disabled,
|
disabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: StockSearchFormProps) {
|
}: StockSearchFormProps) {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex gap-2">
|
<form onSubmit={onSubmit} className="flex gap-2">
|
||||||
|
{/* ========== SEARCH INPUT ========== */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="종목명 또는 코드(6자리) 입력..."
|
|
||||||
className="pl-9"
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => onKeywordChange(e.target.value)}
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
|
onFocus={onInputFocus}
|
||||||
|
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
|
||||||
|
autoComplete="off"
|
||||||
|
className="pl-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ========== SUBMIT BUTTON ========== */}
|
||||||
<Button type="submit" disabled={disabled || isLoading}>
|
<Button type="submit" disabled={disabled || isLoading}>
|
||||||
{isLoading ? "검색 중..." : "검색"}
|
{isLoading ? "검색 중..." : "검색"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
88
features/dashboard/components/search/StockSearchHistory.tsx
Normal file
88
features/dashboard/components/search/StockSearchHistory.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Clock3, Trash2, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { DashboardStockSearchHistoryItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
|
interface StockSearchHistoryProps {
|
||||||
|
items: DashboardStockSearchHistoryItem[];
|
||||||
|
selectedSymbol?: string;
|
||||||
|
onSelect: (item: DashboardStockSearchHistoryItem) => void;
|
||||||
|
onRemove: (symbol: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 최근 검색 종목 목록을 보여주고, 재검색/개별삭제/전체삭제를 제공합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 검색 패널에서 종목 재선택 UI로 사용합니다.
|
||||||
|
* @see features/dashboard/hooks/useStockSearch.ts searchHistory 상태를 화면에 렌더링합니다.
|
||||||
|
*/
|
||||||
|
export function StockSearchHistory({
|
||||||
|
items,
|
||||||
|
selectedSymbol,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
}: StockSearchHistoryProps) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-md border border-brand-200/80 bg-brand-50/45 p-2 dark:border-brand-700/50 dark:bg-brand-900/26">
|
||||||
|
{/* ========== HISTORY HEADER ========== */}
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2 px-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-brand-700 dark:text-brand-200">
|
||||||
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
|
최근 검색 종목
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClear}
|
||||||
|
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground dark:text-brand-100/75 dark:hover:text-brand-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
전체 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== HISTORY LIST ========== */}
|
||||||
|
<div className="max-h-36 space-y-1 overflow-y-auto pr-1">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = item.symbol === selectedSymbol;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.symbol} className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 flex-1 justify-between rounded-md border border-transparent px-2.5",
|
||||||
|
"text-left hover:bg-white/80 dark:hover:bg-brand-800/35",
|
||||||
|
isSelected &&
|
||||||
|
"border-brand-300 bg-white text-brand-700 dark:border-brand-500/55 dark:bg-brand-800/40 dark:text-brand-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-medium">{item.name}</span>
|
||||||
|
<span className="ml-2 shrink-0 text-[11px] text-muted-foreground dark:text-brand-100/70">
|
||||||
|
{item.symbol}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onRemove(item.symbol)}
|
||||||
|
aria-label={`${item.name} 히스토리 삭제`}
|
||||||
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-white/80 hover:text-foreground dark:text-brand-100/70 dark:hover:bg-brand-800/35 dark:hover:text-brand-50"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,68 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
|
||||||
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
|
|
||||||
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
|
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
|
||||||
|
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
|
import type {
|
||||||
|
DashboardStockSearchHistoryItem,
|
||||||
|
DashboardStockSearchItem,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
|
||||||
|
const SEARCH_HISTORY_LIMIT = 12;
|
||||||
|
|
||||||
|
interface StoredSearchHistory {
|
||||||
|
version: 1;
|
||||||
|
items: DashboardStockSearchHistoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSearchHistory(): DashboardStockSearchHistoryItem[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SEARCH_HISTORY_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as StoredSearchHistory;
|
||||||
|
if (parsed?.version !== 1 || !Array.isArray(parsed.items)) return [];
|
||||||
|
|
||||||
|
return parsed.items
|
||||||
|
.filter((item) => item?.symbol && item?.name && item?.market)
|
||||||
|
.slice(0, SEARCH_HISTORY_LIMIT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const payload: StoredSearchHistory = {
|
||||||
|
version: 1,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
|
||||||
|
* @see features/dashboard/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다.
|
||||||
|
*/
|
||||||
export function useStockSearch() {
|
export function useStockSearch() {
|
||||||
|
// ========== SEARCH STATE ==========
|
||||||
const [keyword, setKeyword] = useState("삼성전자");
|
const [keyword, setKeyword] = useState("삼성전자");
|
||||||
const [searchResults, setSearchResults] = useState<
|
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||||
DashboardStockSearchItem[]
|
|
||||||
>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
|
// ========== SEARCH HISTORY STATE ==========
|
||||||
|
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
|
||||||
|
() => readSearchHistory(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const loadSearch = useCallback(async (query: string) => {
|
const loadSearch = useCallback(async (query: string) => {
|
||||||
const requestId = ++requestIdRef.current;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
@@ -24,29 +72,28 @@ export function useStockSearch() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchStockSearch(query, controller.signal);
|
const data = await fetchStockSearch(query, controller.signal);
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setSearchResults(data.items);
|
setSearchResults(data.items);
|
||||||
}
|
|
||||||
return data.items;
|
return data.items;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (requestId === requestIdRef.current) {
|
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "종목 검색 중 오류가 발생했습니다.",
|
: "종목 검색 중 오류가 발생했습니다.",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === requestIdRef.current) {
|
if (!controller.signal.aborted) {
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 검색어를 받아 종목 검색 API를 호출합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다.
|
||||||
|
*/
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(query: string, credentials: KisRuntimeCredentials | null) => {
|
(query: string, credentials: KisRuntimeCredentials | null) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
@@ -70,6 +117,10 @@ export function useStockSearch() {
|
|||||||
[loadSearch],
|
[loadSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
|
||||||
|
*/
|
||||||
const clearSearch = useCallback(() => {
|
const clearSearch = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -77,15 +128,64 @@ export function useStockSearch() {
|
|||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
|
||||||
|
*/
|
||||||
|
const setSearchError = useCallback((message: string | null) => {
|
||||||
|
setError(message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다.
|
||||||
|
*/
|
||||||
|
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
|
||||||
|
setSearchHistory((prev) => {
|
||||||
|
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
|
||||||
|
const nextItems: DashboardStockSearchHistoryItem[] = [
|
||||||
|
{ ...item, savedAt: Date.now() },
|
||||||
|
...deduped,
|
||||||
|
].slice(0, SEARCH_HISTORY_LIMIT);
|
||||||
|
|
||||||
|
writeSearchHistory(nextItems);
|
||||||
|
return nextItems;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 종목코드 기준으로 히스토리 항목을 삭제합니다.
|
||||||
|
* @see features/dashboard/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다.
|
||||||
|
*/
|
||||||
|
const removeSearchHistory = useCallback((symbol: string) => {
|
||||||
|
setSearchHistory((prev) => {
|
||||||
|
const nextItems = prev.filter((item) => item.symbol !== symbol);
|
||||||
|
writeSearchHistory(nextItems);
|
||||||
|
return nextItems;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 저장된 검색 히스토리를 전체 삭제합니다.
|
||||||
|
* @see features/dashboard/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
|
||||||
|
*/
|
||||||
|
const clearSearchHistory = useCallback(() => {
|
||||||
|
setSearchHistory([]);
|
||||||
|
writeSearchHistory([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keyword,
|
keyword,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
searchResults,
|
searchResults,
|
||||||
setSearchResults,
|
|
||||||
error,
|
error,
|
||||||
setError,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
search,
|
search,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
|
setSearchError,
|
||||||
|
searchHistory,
|
||||||
|
appendSearchHistory,
|
||||||
|
removeSearchHistory,
|
||||||
|
clearSearchHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export interface DashboardStockSearchItem {
|
|||||||
market: "KOSPI" | "KOSDAQ";
|
market: "KOSPI" | "KOSDAQ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 히스토리 1개 항목
|
||||||
|
* @see features/dashboard/hooks/useStockSearch.ts localStorage에 저장/복원할 때 사용합니다.
|
||||||
|
*/
|
||||||
|
export interface DashboardStockSearchHistoryItem
|
||||||
|
extends DashboardStockSearchItem {
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 종목 검색 API 응답
|
* 종목 검색 API 응답
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function Header({
|
|||||||
: "text-foreground group-hover:text-primary",
|
: "text-foreground group-hover:text-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
AutoTrade
|
Jurini
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react";
|
import {
|
||||||
|
BarChart2,
|
||||||
|
ChevronLeft,
|
||||||
|
Home,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { MenuItem } from "../types";
|
import { MenuItem } from "../types";
|
||||||
|
|
||||||
const MENU_ITEMS: MenuItem[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
@@ -52,9 +60,36 @@ const MENU_ITEMS: MenuItem[] = [
|
|||||||
*/
|
*/
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="group/sidebar hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white px-2 py-5 transition-[width] duration-200 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-[74px] md:hover:w-64 md:focus-within:w-64">
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"relative hidden h-[calc(100vh-4rem)] shrink-0 overflow-x-visible overflow-y-auto border-r border-brand-100 bg-white px-2 py-5 transition-[width] duration-200 dark:border-brand-900/40 dark:bg-background md:sticky md:top-16 md:block",
|
||||||
|
isExpanded ? "md:w-64" : "md:w-[74px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
|
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-3 top-20 z-50 hidden h-8 w-8 items-center justify-center rounded-full",
|
||||||
|
"border border-zinc-200/50 bg-white/80 shadow-lg backdrop-blur-md transition-all duration-300",
|
||||||
|
"hover:scale-110 hover:bg-white active:scale-95",
|
||||||
|
"dark:border-zinc-800/50 dark:bg-zinc-900/80 dark:hover:bg-zinc-900",
|
||||||
|
"md:flex",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-zinc-600 transition-transform duration-300 dark:text-zinc-300",
|
||||||
|
isExpanded ? "rotate-0" : "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-1.5" />
|
||||||
{/* ========== SIDEBAR ITEMS ========== */}
|
{/* ========== SIDEBAR ITEMS ========== */}
|
||||||
<div className="flex flex-col space-y-1.5">
|
<div className="flex flex-col space-y-1.5">
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
@@ -69,10 +104,10 @@ export function Sidebar() {
|
|||||||
title={item.title}
|
title={item.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
||||||
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
|
"hover:bg-brand-50 hover:text-brand-800 dark:hover:bg-brand-900/30 dark:hover:text-brand-100",
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-100 text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50"
|
? "bg-brand-100 text-brand-800 shadow-sm dark:bg-brand-900/40 dark:text-brand-100"
|
||||||
: "text-zinc-500 dark:text-zinc-400",
|
: "text-muted-foreground dark:text-brand-200/80",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ========== ACTIVE BAR ========== */}
|
{/* ========== ACTIVE BAR ========== */}
|
||||||
@@ -88,17 +123,24 @@ export function Sidebar() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-5 w-5 shrink-0 transition-colors",
|
"h-5 w-5 shrink-0 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "text-zinc-900 dark:text-zinc-50"
|
? "text-brand-700 dark:text-brand-200"
|
||||||
: "text-zinc-400 group-hover/item:text-zinc-900 dark:text-zinc-500 dark:group-hover/item:text-zinc-50",
|
: "text-zinc-400 group-hover/item:text-brand-700 dark:text-brand-300/70 dark:group-hover/item:text-brand-200",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{item.badge && (
|
{item.badge && !isExpanded && (
|
||||||
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500 md:group-hover/sidebar:hidden md:group-focus-within/sidebar:hidden" />
|
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ========== LABEL (EXPAND ON HOVER/FOCUS) ========== */}
|
{/* ========== LABEL (EXPAND ON TOGGLE) ========== */}
|
||||||
<span className="ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200 md:max-w-0 md:opacity-0 md:group-hover/sidebar:max-w-[180px] md:group-hover/sidebar:opacity-100 md:group-focus-within/sidebar:max-w-[180px] md:group-focus-within/sidebar:opacity-100">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200",
|
||||||
|
isExpanded
|
||||||
|
? "max-w-[180px] opacity-100"
|
||||||
|
: "max-w-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span className="truncate font-medium">{item.title}</span>
|
<span className="truncate font-medium">{item.title}</span>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
||||||
@@ -120,15 +162,22 @@ export function Sidebar() {
|
|||||||
*/
|
*/
|
||||||
export function MobileBottomNav() {
|
export function MobileBottomNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const bottomItems = MENU_ITEMS.filter((item) => item.showInBottomNav !== false);
|
const bottomItems = MENU_ITEMS.filter(
|
||||||
|
(item) => item.showInBottomNav !== false,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="모바일 빠른 메뉴"
|
aria-label="모바일 빠른 메뉴"
|
||||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-zinc-200 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-zinc-800 dark:bg-black/95 dark:supports-backdrop-filter:bg-black/80 md:hidden"
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-brand-100 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-brand-900/40 dark:bg-background/95 dark:supports-backdrop-filter:bg-background/80 md:hidden"
|
||||||
>
|
>
|
||||||
{/* ========== BOTTOM NAV ITEMS ========== */}
|
{/* ========== BOTTOM NAV ITEMS ========== */}
|
||||||
<div className={cn("grid", bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5")}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid",
|
||||||
|
bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{bottomItems.map((item) => {
|
{bottomItems.map((item) => {
|
||||||
const isActive = item.matchExact
|
const isActive = item.matchExact
|
||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
@@ -142,11 +191,13 @@ export function MobileBottomNav() {
|
|||||||
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "text-brand-700"
|
? "text-brand-700"
|
||||||
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50",
|
: "text-muted-foreground hover:text-brand-700 dark:text-brand-200/80 dark:hover:text-brand-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<item.icon className={cn("h-4 w-4", isActive && "text-brand-600")} />
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", isActive && "text-brand-600")}
|
||||||
|
/>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -865,9 +865,27 @@ export async function getDomesticChart(
|
|||||||
);
|
);
|
||||||
rawRows = parseOutput2Rows(response);
|
rawRows = parseOutput2Rows(response);
|
||||||
|
|
||||||
// 오늘 데이터 다음은 '어제 마감'
|
// 초기 조회는 "오늘 가장 오래된 분봉" 기준으로 같은 날 이전 분봉을 우선 이어 붙입니다.
|
||||||
const todayYmd = nowYmdInKst();
|
const oldestRow = rawRows[rawRows.length - 1];
|
||||||
nextCursor = shiftYmd(todayYmd, -1) + "153000";
|
const oldestTimeRaw = oldestRow
|
||||||
|
? readRowString(oldestRow, "stck_cntg_hour", "STCK_CNTG_HOUR")
|
||||||
|
: "";
|
||||||
|
const oldestDateRaw = oldestRow
|
||||||
|
? readRowString(oldestRow, "stck_bsop_date", "STCK_BSOP_DATE")
|
||||||
|
: "";
|
||||||
|
const oldestTime = /^\d{6}$/.test(oldestTimeRaw)
|
||||||
|
? oldestTimeRaw
|
||||||
|
: /^\d{4}$/.test(oldestTimeRaw)
|
||||||
|
? `${oldestTimeRaw}00`
|
||||||
|
: "";
|
||||||
|
const oldestDate = /^\d{8}$/.test(oldestDateRaw)
|
||||||
|
? oldestDateRaw
|
||||||
|
: nowYmdInKst();
|
||||||
|
|
||||||
|
nextCursor =
|
||||||
|
oldestTime && Number(oldestTime) > 90000
|
||||||
|
? oldestDate + subOneMinute(oldestTime)
|
||||||
|
: shiftYmd(oldestDate, -1) + "153000";
|
||||||
}
|
}
|
||||||
|
|
||||||
const candles = mergeCandlesByTimestamp(
|
const candles = mergeCandlesByTimestamp(
|
||||||
|
|||||||
Reference in New Issue
Block a user