테마 적용

This commit is contained in:
2026-02-11 14:06:06 +09:00
parent def87bd47a
commit 95291e6922
30 changed files with 1209 additions and 496 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 {

View File

@@ -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. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
}; };
/** /**

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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

View File

@@ -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>
); );

View File

@@ -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>
)} )}

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View File

@@ -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,
}; };
} }

View File

@@ -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 응답
*/ */

View File

@@ -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>

View File

@@ -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" />
)} )}

View File

@@ -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(