2 Commits

Author SHA1 Message Date
b73867c65d [공통컴포넌트] alert 제작 2026-02-13 16:12:08 +09:00
7c194d7452 보안 점검 및 대시보드 문구 수정 2026-02-13 15:44:41 +09:00
36 changed files with 1113 additions and 438 deletions

View File

@@ -7,23 +7,3 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위) # 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30 NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
# KIS 거래 모드: real(실전) | mock(모의)
KIS_TRADING_ENV=real
# 서버 기본 키를 쓰고 싶은 경우(선택)
KIS_APP_KEY_REAL=
KIS_APP_SECRET_REAL=
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
KIS_APP_KEY_MOCK=
KIS_APP_SECRET_MOCK=
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
# (선택) 서버에서 기본 계좌번호를 사용할 경우
# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01
# KIS_ACCOUNT_PRODUCT_CODE=01
KIS_ACCOUNT_NO=
KIS_ACCOUNT_PRODUCT_CODE=

View File

@@ -5,6 +5,7 @@
- 모든 응답과 설명은 한국어로 작성. - 모든 응답과 설명은 한국어로 작성.
- 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인. - 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다.
## 프로젝트 요약 ## 프로젝트 요약
@@ -54,3 +55,8 @@
- `mcp:kis-code-assistant-mcp` 활용 - `mcp:kis-code-assistant-mcp` 활용
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용 - `C:\dev\auto-trade\.tmp\open-trading-api` 활용
- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다. - API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.
## 소개문구
- 불안감을 해소하고 확신을 주는 문구
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략

View File

@@ -66,11 +66,10 @@ Copy-Item .env.example .env.local
- `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - `NEXT_PUBLIC_SUPABASE_ANON_KEY`
KIS는 선택입니다(직접 입력 방식이면 서버 기본 키 없이도 동작 가능). KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다.
- `KIS_TRADING_ENV=real|mock` - 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리
- `KIS_APP_KEY_REAL`, `KIS_APP_SECRET_REAL` (선택) - 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작
- `KIS_APP_KEY_MOCK`, `KIS_APP_SECRET_MOCK` (선택)
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장) - `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
### 5-4. 로컬 실행 ### 5-4. 로컬 실행

View File

@@ -4,34 +4,13 @@
*/ */
import Link from "next/link"; import Link from "next/link";
import type { LucideIcon } from "lucide-react"; import { ArrowRight, Sparkles } 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";
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";
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
interface ValuePoint {
value: string;
label: string;
detail: string;
}
interface FeatureItem {
icon: LucideIcon;
eyebrow: string;
title: string;
description: string;
}
interface StartStep { interface StartStep {
step: string; step: string;
@@ -39,311 +18,195 @@ interface StartStep {
description: 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[] = [ const START_STEPS: StartStep[] = [
{ {
step: "STEP 01", step: "01",
title: "계정 연결", title: "1분이면 충분해요",
description: "안내에 따라 거래 계정을 안전하게 연결합니다.", description:
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.",
}, },
{ {
step: "STEP 02", step: "02",
title: "성향 선택", title: "내 스타일대로 골라보세요",
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.", description:
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.",
}, },
{ {
step: "STEP 03", step: "03",
title: "자동 실행 시작", title: "이제 일상을 즐기세요",
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.", description:
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.",
}, },
]; ];
/** /**
* 홈 메인 랜딩 페이지 * 홈 메인 랜딩 페이지
* @returns 랜딩 UI * @returns 랜딩 UI
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
*/ */
export default async function HomePage() { export default async function HomePage() {
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP; const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기"; 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-black text-white selection:bg-brand-500/30">
<Header user={user} showDashboardLink={true} blendWithBackground={true} /> <Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="relative isolate flex-1 overflow-hidden pt-16"> <main className="relative isolate flex-1">
{/* ========== SHADER BACKGROUND SECTION ========== */} {/* ========== BACKGROUND ========== */}
<ShaderBackground opacity={1} className="-z-20" /> <ShaderBackground opacity={0.6} className="-z-20" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
<div <div
aria-hidden="true" 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" className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
/>
<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-14 pt-14 md:pt-24"> <section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10"> <div className="flex flex-col items-center text-center">
<div className="lg:col-span-7"> <span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
<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" /> , JOORIN-E
</span>
</span>
<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"> <h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
, ,
<br />
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
</span>
</h1>
<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>
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
<Button
asChild
size="lg"
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"
>
<Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
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={secondaryCtaHref}>{secondaryCtaLabel}</Link>
</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 className="relative lg:col-span-5">
<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" />
<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">
<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>
<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">
<Activity className="h-3.5 w-3.5" />
</span>
</div>
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
<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 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>
</section>
{/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<br /> <br />
<span className="text-brand-200"> </span> <span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
</h2> .
</div> </span>
</h1>
<div className="mt-8 grid gap-6 md:grid-cols-3"> <p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
{FEATURE_ITEMS.map((feature) => { , .
const FeatureIcon = feature.icon; <br className="hidden md:block" />
24 .
</p>
return ( <div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
<Card <Button
key={feature.title} asChild
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" size="lg"
> className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
<CardHeader> >
<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"> <Link href={primaryCtaHref}>
<FeatureIcon className="h-6 w-6" /> {primaryCtaLabel}
</div> <ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
<p className="text-xs font-semibold tracking-wide text-brand-200">{feature.eyebrow}</p> </Link>
<CardTitle className="text-xl leading-snug text-white">{feature.title}</CardTitle> </Button>
</CardHeader> </div>
<CardContent className="text-sm leading-relaxed text-white/75">
{feature.description}
</CardContent>
</Card>
);
})}
</div> </div>
</section> </section>
{/* ========== START STEP SECTION ========== */} {/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16"> <section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10"> <AnimatedBrandTone />
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end"> </section>
<div>
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p> {/* ========== SIMPLE STEPS SECTION ========== */}
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl"> <section className="container mx-auto max-w-5xl px-4 py-24">
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
<br /> <div className="flex-1 text-center md:text-left">
<span className="text-brand-200"> 3 </span> <h2 className="text-3xl font-black md:text-5xl">
</h2>
</div> <br />
<p className="max-w-sm text-sm leading-relaxed text-white/70"> <span className="text-brand-300"> 3 .</span>
, . </h2>
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
JOORIN-E가 .
<br />
&apos;&apos; .
</p> </p>
</div> </div>
<div className="mt-8 grid gap-4 md:grid-cols-3"> <div className="flex-2 grid w-full gap-4 md:grid-cols-1">
{START_STEPS.map((item) => ( {START_STEPS.map((item) => (
<div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5"> <div
<p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p> key={item.step}
<p className="mt-2 text-lg font-semibold text-white">{item.title}</p> className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
<p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p> >
<span className="text-3xl font-black text-brand-500/50 group-hover:text-brand-500">
{item.step}
</span>
<div>
<h3 className="text-lg font-bold text-white">
{item.title}
</h3>
<p className="mt-1 text-sm text-white/50">
{item.description}
</p>
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section>
{/* ========== CTA SECTION ========== */} {/* 보안 안심 문구 (사용자 요청 반영) */}
<section className="container mx-auto max-w-7xl px-4 pb-20"> <div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
<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 max-w-2xl flex-col items-center gap-4 rounded-2xl border border-brand-500/20 bg-brand-500/5 p-8 backdrop-blur-sm md:flex-row md:gap-8 md:text-left">
<div <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
aria-hidden="true" <svg
className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl" xmlns="http://www.w3.org/2000/svg"
/> width="24"
<div height="24"
aria-hidden="true" viewBox="0 0 24 24"
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl" fill="none"
/> stroke="currentColor"
strokeWidth="2"
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left"> strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-shield-check"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div> <div>
<p className="text-sm font-semibold text-brand-100"> </p> <h3 className="text-lg font-bold text-brand-100">
<h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl"> , ?
, </h3>
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
<strong className="text-brand-200">
, .
</strong>
<br /> <br />
JOORIN-E는 API
</h2> .
<p className="mt-3 text-sm text-white/75 md:text-base"> <br className="hidden md:block" />
. ()
,
<br className="hidden md:block" />
.
</p> </p>
</div> </div>
</div>
</div>
</section>
{/* ========== FINAL CTA SECTION ========== */}
<section className="container mx-auto max-w-5xl px-4 py-32">
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
<h2 className="text-3xl font-black md:text-6xl">
.
<br />
.
</h2>
<div className="mt-12 flex justify-center">
<Button <Button
asChild asChild
size="lg" 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" className="h-16 rounded-full bg-white px-12 text-xl font-black text-black transition-all hover:scale-110 active:scale-95"
> >
<Link href={primaryCtaHref}> <Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button> </Button>
</div> </div>
<p className="mt-8 text-sm text-white/30">
© 2026 POPUP STUDIO. All rights reserved.
</p>
</div> </div>
</section> </section>
</main> </main>

18
app/api/kis/_session.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createClient } from "@/utils/supabase/server";
/**
* @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
* @returns 로그인 세션 존재 여부
* @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
* @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
* @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
*/
export async function hasKisApiSession() {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
return Boolean(!error && user);
}

View File

@@ -26,7 +26,7 @@ export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialIn
} }
/** /**
* @description 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다. * @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
* @param headers 요청 헤더 * @param headers 요청 헤더
* @returns 계좌번호 파트(8 + 2) 또는 null * @returns 계좌번호 파트(8 + 2) 또는 null
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱 * @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
@@ -35,11 +35,5 @@ export function readKisAccountParts(headers: Headers) {
const headerAccountNo = headers.get("x-kis-account-no"); const headerAccountNo = headers.get("x-kis-account-no");
const headerAccountProductCode = headers.get("x-kis-account-product-code"); const headerAccountProductCode = headers.get("x-kis-account-product-code");
const envAccountNo = process.env.KIS_ACCOUNT_NO; return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
return (
parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
parseKisAccountParts(envAccountNo, envAccountProductCode)
);
} }

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types"; import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard"; import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -20,6 +21,11 @@ import {
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링 * @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types"; import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard"; import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { import {
readKisAccountParts, readKisAccountParts,
readKisCredentialsFromHeaders, readKisCredentialsFromHeaders,
@@ -18,6 +19,11 @@ import {
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다. * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {

View File

@@ -6,6 +6,7 @@ import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic"; import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m", "1m",
@@ -20,6 +21,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API * @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = ( const timeframe = (

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types"; import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard"; import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared"; import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/** /**
@@ -15,6 +16,11 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다. * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {

View File

@@ -4,6 +4,7 @@ import {
DashboardStockCashOrderRequest, DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse, DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { import {
KisCredentialInput, KisCredentialInput,
hasKisConfig, hasKisConfig,
@@ -17,12 +18,25 @@ import {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers); const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
},
{ status: 401 },
);
}
if (!hasKisConfig(credentials)) { if (!hasKisConfig(credentials)) {
return NextResponse.json( return NextResponse.json(
{ {
ok: false, ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv), tradingEnv,
message: "KIS API 키 설정이 필요합니다.", message: "KIS API 키 설정이 필요합니다.",
}, },
{ status: 400 }, { status: 400 },
@@ -42,7 +56,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
ok: false, ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv), tradingEnv,
message: message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)", "주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
}, },
@@ -65,7 +79,7 @@ export async function POST(request: NextRequest) {
const response: DashboardStockCashOrderResponse = { const response: DashboardStockCashOrderResponse = {
ok: true, ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv), tradingEnv,
message: "주문이 전송되었습니다.", message: "주문이 전송되었습니다.",
orderNo: output.ODNO, orderNo: output.ODNO,
orderTime: output.ORD_TMD, orderTime: output.ORD_TMD,
@@ -81,7 +95,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
ok: false, ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv), tradingEnv,
message, message,
}, },
{ status: 500 }, { status: 500 },

View File

@@ -4,6 +4,7 @@ import {
KisDomesticOrderBookOutput, KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic"; } from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { import {
KisCredentialInput, KisCredentialInput,
hasKisConfig, hasKisConfig,
@@ -20,6 +21,11 @@ import {
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();

View File

@@ -2,6 +2,7 @@ import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types"; import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config"; import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic"; import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
@@ -20,6 +21,11 @@ import {
* @returns 대시보드 상세 모델 * @returns 대시보드 상세 모델
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim(); const symbol = (searchParams.get("symbol") ?? "").trim();

View File

@@ -4,6 +4,7 @@ import type {
DashboardStockSearchResponse, DashboardStockSearchResponse,
KoreanStockIndexItem, KoreanStockIndexItem,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10; const SEARCH_LIMIT = 10;
@@ -26,6 +27,11 @@ const SEARCH_LIMIT = 10;
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다. * @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다. // [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const query = (searchParams.get("q") ?? "").trim(); const query = (searchParams.get("q") ?? "").trim();

View File

@@ -5,6 +5,7 @@ import {
validateKisCredentialInput, validateKisCredentialInput,
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token"; import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request); const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types"; import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account"; import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client"; import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config"; import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
@@ -43,6 +44,22 @@ const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수 * @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const fallbackTradingEnv = normalizeTradingEnv(
request.headers.get("x-kis-trading-env") ?? undefined,
);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv: fallbackTradingEnv,
message: "로그인이 필요합니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 401 },
);
}
let body: KisProfileValidateRequestBody = {}; let body: KisProfileValidateRequestBody = {};
try { try {
@@ -51,7 +68,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
ok: false, ok: false,
tradingEnv: "mock", tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.", message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">, } satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 }, { status: 400 },

View File

@@ -5,6 +5,7 @@ import {
validateKisCredentialInput, validateKisCredentialInput,
} from "@/lib/kis/request"; } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token"; import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
/** /**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request); const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types"; import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval"; import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config"; import { normalizeTradingEnv } from "@/lib/kis/config";
import { import {
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request); const credentials = await parseKisCredentialRequest(request);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
const invalidMessage = validateKisCredentialInput(credentials); const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) { if (invalidMessage) {
return NextResponse.json( return NextResponse.json(

View File

@@ -13,6 +13,7 @@ import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider"; import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager"; import { SessionManager } from "@/features/auth/components/session-manager";
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
@@ -33,9 +34,9 @@ const outfit = Outfit({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매", title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description: description:
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.", "주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
}; };
/** /**
@@ -62,6 +63,7 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<SessionManager /> <SessionManager />
<GlobalAlertModal />
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
<Toaster <Toaster
richColors richColors

View File

@@ -0,0 +1,42 @@
# Korean Stocks 동기화
`korean-stocks.json`은 수동 편집 파일이 아니라 자동 생성 파일입니다.
직접 수정하지 말고 동기화 스크립트로 갱신하세요.
## 실행 명령
```bash
npm run sync:stocks
```
- KIS 최신 KOSPI/KOSDAQ 마스터 파일을 내려받아
`features/trade/data/korean-stocks.json`을 다시 생성합니다.
```bash
npm run sync:stocks:check
```
- 현재 파일이 최신인지 검사합니다.
- 갱신이 필요하면 종료 코드 `1`로 끝납니다.
```bash
npm run sync:stocks -- --dry-run
```
- 원격 파일 파싱/검증만 하고 저장은 하지 않습니다.
## 권장 운영 방법
1. 하루 1회(또는 배포 전) `npm run sync:stocks` 실행
2. `npm run lint`, `npm run build`로 기본 검증
3. 갱신된 `features/trade/data/korean-stocks.json` 커밋
## 참고
- 데이터 출처:
- `https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip`
- `https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip`
- 비정상 데이터 저장을 막기 위해 최소 건수 검증(안전장치)을 넣었습니다.
- 임시 파일 저장 후 교체(원자적 저장) 방식이라 중간 손상 위험을 줄입니다.
- 공식 문서:
- `https://apiportal.koreainvestment.com/apiservice-category`

View File

@@ -0,0 +1,146 @@
# Global Alert System 사용 가이드
이 문서는 애플리케이션 전역에서 사용 가능한 `Global Alert System`의 설계 목적, 설치 방법, 그리고 사용법을 설명합니다.
## 1. 개요 (Overview)
Global Alert System은 Zustand 상태 관리 라이브러리와 Shadcn/ui의 `AlertDialog` 컴포넌트를 결합하여 만든 전역 알림 시스템입니다.
복잡한 모달 로직을 매번 구현할 필요 없이, `useGlobalAlert` 훅 하나로 어디서든 일관된 UI의 알림 및 확인 창을 호출할 수 있습니다.
### 주요 특징
- **전역 상태 관리**: `useGlobalAlertStore`를 통해 모달의 상태를 중앙에서 관리합니다.
- **간편한 Hook**: `useGlobalAlert` 훅을 통해 직관적인 API (`alert.success`, `alert.confirm` 등)를 제공합니다.
- **다양한 타입 지원**: Success, Error, Warning, Info 등 상황에 맞는 스타일과 아이콘을 자동으로 적용합니다.
- **비동기 지원**: 확인/취소 버튼 클릭 시 콜백 함수를 실행할 수 있습니다.
---
## 2. 설치 및 설정 (Setup)
이미 `app/layout.tsx`에 설정되어 있으므로, 개발자는 별도의 설정 없이 바로 사용할 수 있습니다.
### 파일 구조
```
features/layout/
├── components/
│ └── GlobalAlertModal.tsx # 실제 렌더링되는 모달 컴포넌트
├── hooks/
│ └── use-global-alert.ts # 개발자가 사용하는 Custom Hook
└── stores/
└── use-global-alert-store.ts # Zustand Store
```
### Layout 통합
`app/layout.tsx``GlobalAlertModal`이 이미 등록되어 있습니다.
```tsx
// app/layout.tsx
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<GlobalAlertModal /> {/* 전역 모달 등록 */}
{children}
</body>
</html>
);
}
```
---
## 3. 사용법 (Usage)
### Hook 가져오기
```tsx
import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert";
const { alert } = useGlobalAlert();
```
### 기본 알림 (Alert)
사용자에게 단순히 정보를 전달하고 확인 버튼만 있는 알림입니다.
```tsx
// 1. 성공 알림
alert.success("저장이 완료되었습니다.");
// 2. 에러 알림
alert.error("데이터 불러오기에 실패했습니다.");
// 3. 경고 알림
alert.warning("입력 값이 올바르지 않습니다.");
// 4. 정보 알림
alert.info("새로운 버전이 업데이트되었습니다.");
```
옵션을 추가하여 제목이나 버튼 텍스트를 변경할 수 있습니다.
```tsx
alert.success("저장 완료", {
title: "성공", // 기본값: 타입에 따른 제목 (예: "성공", "오류")
confirmLabel: "닫기", // 기본값: "확인"
});
```
### 확인 대화상자 (Confirm)
사용자의 선택(확인/취소)을 요구하는 대화상자입니다.
```tsx
alert.confirm("정말로 삭제하시겠습니까?", {
type: "warning", // 기본값: warning (아이콘과 색상 변경됨)
confirmLabel: "삭제",
cancelLabel: "취소",
onConfirm: () => {
console.log("삭제 버튼 클릭됨");
// 여기에 삭제 로직 추가
},
onCancel: () => {
console.log("취소 버튼 클릭됨");
},
});
```
---
## 4. API Reference
### `useGlobalAlert()`
Hook은 `alert` 객체와 `close` 함수를 반환합니다.
#### `alert` Methods
| 메서드 | 설명 | 파라미터 |
| --------- | ----------------------- | ---------------------------------------------- |
| `success` | 성공 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `error` | 오류 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `warning` | 경고 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `info` | 정보 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
| `confirm` | 확인/취소 대화상자 표시 | `(message: ReactNode, options?: AlertOptions)` |
#### `AlertOptions` Interface
```typescript
interface AlertOptions {
title?: ReactNode; // 모달 제목 (생략 시 타입에 맞는 기본 제목)
confirmLabel?: string; // 확인 버튼 텍스트 (기본: "확인")
cancelLabel?: string; // 취소 버튼 텍스트 (Confirm 모드에서 기본: "취소")
onConfirm?: () => void; // 확인 버튼 클릭 시 실행될 콜백
onCancel?: () => void; // 취소 버튼 클릭 시 실행될 콜백
type?: AlertType; // 알림 타입 ("success" | "error" | "warning" | "info")
}
```

View File

@@ -0,0 +1,115 @@
"use client";
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
const TONE_PHRASES = [
{ q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
{
q: "내 돈, 정말 안전할까?",
a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
},
{
q: "손실 날까 봐 불안해요...",
a: "걱정하지 마. 안전 장치가 24시간 작동해.",
},
{
q: "복잡한 건 딱 질색인데..",
a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
},
];
/**
* @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
*/
export function AnimatedBrandTone() {
const [index, setIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
}, 5000);
return () => clearInterval(timer);
}, []);
return (
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
<AnimatePresence mode="wait">
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-center w-full"
>
{/* 질문 (Q) */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-sm font-medium text-brand-300/60 md:text-lg"
>
&ldquo;{TONE_PHRASES[index].q}&rdquo;
</motion.p>
{/* 답변 (A) - 타이핑 효과 */}
<div className="mt-8 flex flex-col items-center gap-2">
<h2 className="text-4xl font-extrabold tracking-wide text-white drop-shadow-lg md:text-6xl lg:text-7xl">
<div className="inline-block break-keep whitespace-pre-wrap leading-tight">
{TONE_PHRASES[index].a.split("").map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0,
delay: 0.5 + i * 0.1, // 글자당 0.1초 딜레이로 타이핑 효과
}}
className={cn(
"inline-block",
// 앞부분 강조 색상 로직은 단순화하거나 유지 (여기서는 전체 텍스트 톤 유지)
i < 5 ? "text-brand-300" : "text-white",
)}
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
{/* 깜빡이는 커서 */}
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
transition={{
duration: 0.8,
repeat: Infinity,
ease: "linear",
}}
className="ml-1 inline-block h-[0.8em] w-1.5 bg-brand-400 align-middle shadow-[0_0_10px_rgba(45,212,191,0.5)]"
/>
</div>
</h2>
</div>
</motion.div>
</AnimatePresence>
{/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
<div className="mt-16 flex gap-3">
{TONE_PHRASES.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={cn(
"h-1.5 transition-all duration-500 rounded-full",
i === index
? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
: "w-2 bg-white/10 hover:bg-white/20",
)}
aria-label={`Go to slide ${i + 1}`}
/>
))}
</div>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import { import {
formatCurrency, formatCurrency,
formatPercent, formatPercent,
@@ -45,6 +46,9 @@ export function StockDetailPreview({
totalAmount, totalAmount,
}: StockDetailPreviewProps) { }: StockDetailPreviewProps) {
const router = useRouter(); const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리 // [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달 // @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
const currentPrice = holding?.currentPrice ?? 0; const currentPrice = holding?.currentPrice ?? 0;
@@ -92,11 +96,14 @@ export function StockDetailPreview({
<CardDescription className="flex items-center gap-1.5 flex-wrap"> <CardDescription className="flex items-center gap-1.5 flex-wrap">
<button <button
type="button" type="button"
onClick={() => onClick={() => {
router.push( setPendingTarget({
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`, symbol: holding.symbol,
) name: holding.name,
} market: holding.market,
});
router.push("/trade");
}}
className={cn( className={cn(
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5", "group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
"text-sm font-bold text-brand-700 transition-all cursor-pointer", "text-sm font-bold text-brand-700 transition-all cursor-pointer",

View File

@@ -0,0 +1,110 @@
"use client";
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useGlobalAlertStore } from "@/features/layout/stores/use-global-alert-store";
import { cn } from "@/lib/utils";
export function GlobalAlertModal() {
const {
isOpen,
type,
title,
message,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
isSingleButton,
closeAlert,
} = useGlobalAlertStore();
const handleOpenChange = (open: boolean) => {
if (!open) {
closeAlert();
}
};
const handleConfirm = () => {
onConfirm?.();
closeAlert();
};
const handleCancel = () => {
onCancel?.();
closeAlert();
};
const Icon = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
}[type];
const iconColor = {
success: "text-emerald-500",
error: "text-red-500",
warning: "text-amber-500",
info: "text-blue-500",
}[type];
const bgColor = {
success: "bg-emerald-50 dark:bg-emerald-950/20",
error: "bg-red-50 dark:bg-red-950/20",
warning: "bg-amber-50 dark:bg-amber-950/20",
info: "bg-blue-50 dark:bg-blue-950/20",
}[type];
return (
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
<AlertDialogContent className="sm:max-w-[400px]">
<AlertDialogHeader>
<div className="flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left">
<div
className={cn(
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full",
bgColor,
iconColor,
)}
>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="text-sm leading-relaxed">
{message}
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4 sm:justify-end">
{!isSingleButton && (
<AlertDialogCancel onClick={handleCancel} className="mt-2 sm:mt-0">
{cancelLabel || "취소"}
</AlertDialogCancel>
)}
<AlertDialogAction
onClick={handleConfirm}
className={cn(
type === "error" && "bg-red-600 hover:bg-red-700",
type === "warning" && "bg-amber-600 hover:bg-amber-700",
type === "success" && "bg-emerald-600 hover:bg-emerald-700",
)}
>
{confirmLabel || "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -20,7 +20,7 @@ export function Logo({
return ( return (
<div <div
className={cn("relative flex items-center gap-2 select-none", className)} className={cn("relative flex items-center gap-2 select-none", className)}
aria-label="Jurini Logo" aria-label="JOORIN-E Logo"
> >
<svg <svg
viewBox="0 0 100 100" viewBox="0 0 100 100"

View File

@@ -101,7 +101,7 @@ export function Header({
: "", : "",
)} )}
> >
<Link href={AUTH_ROUTES.DASHBOARD}></Link> <Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button> </Button>
)} )}
@@ -132,7 +132,7 @@ export function Header({
: "", : "",
)} )}
> >
<Link href={AUTH_ROUTES.SIGNUP}></Link> <Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button> </Button>
</div> </div>
)} )}

View File

@@ -0,0 +1,84 @@
import { ReactNode } from "react";
import {
AlertType,
useGlobalAlertStore,
} from "@/features/layout/stores/use-global-alert-store";
interface AlertOptions {
title?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
onCancel?: () => void;
type?: AlertType;
}
export function useGlobalAlert() {
const openAlert = useGlobalAlertStore((state) => state.openAlert);
const closeAlert = useGlobalAlertStore((state) => state.closeAlert);
const show = (
message: ReactNode,
type: AlertType = "info",
options?: AlertOptions,
) => {
openAlert({
message,
type,
title: options?.title || getDefaultTitle(type),
confirmLabel: options?.confirmLabel || "확인",
cancelLabel: options?.cancelLabel,
onConfirm: options?.onConfirm,
onCancel: options?.onCancel,
isSingleButton: true,
});
};
const confirm = (
message: ReactNode,
type: AlertType = "warning",
options?: AlertOptions,
) => {
openAlert({
message,
type,
title: options?.title || "확인",
confirmLabel: options?.confirmLabel || "확인",
cancelLabel: options?.cancelLabel || "취소",
onConfirm: options?.onConfirm,
onCancel: options?.onCancel,
isSingleButton: false,
});
};
return {
alert: {
success: (message: ReactNode, options?: AlertOptions) =>
show(message, "success", options),
warning: (message: ReactNode, options?: AlertOptions) =>
show(message, "warning", options),
error: (message: ReactNode, options?: AlertOptions) =>
show(message, "error", options),
info: (message: ReactNode, options?: AlertOptions) =>
show(message, "info", options),
confirm: (message: ReactNode, options?: AlertOptions) =>
confirm(message, options?.type || "warning", options),
},
close: closeAlert,
};
}
function getDefaultTitle(type: AlertType) {
switch (type) {
case "success":
return "성공";
case "error":
return "오류";
case "warning":
return "주의";
case "info":
return "알림";
default:
return "알림";
}
}

View File

@@ -0,0 +1,43 @@
import { ReactNode } from "react";
import { create } from "zustand";
export type AlertType = "success" | "warning" | "error" | "info";
export interface AlertState {
isOpen: boolean;
type: AlertType;
title: ReactNode;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
onCancel?: () => void;
// 단일 버튼 모드 여부 (Confirm 모달이 아닌 단순 Alert)
isSingleButton?: boolean;
}
interface AlertActions {
openAlert: (params: Omit<AlertState, "isOpen">) => void;
closeAlert: () => void;
}
const initialState: AlertState = {
isOpen: false,
type: "info",
title: "",
message: "",
confirmLabel: "확인",
cancelLabel: "취소",
isSingleButton: true,
};
export const useGlobalAlertStore = create<AlertState & AlertActions>((set) => ({
...initialState,
openAlert: (params) =>
set({
...initialState, // 초기화 후 설정
...params,
isOpen: true,
}),
closeAlert: () => set({ isOpen: false }),
}));

View File

@@ -162,23 +162,21 @@ export function KisAuthForm() {
)} )}
</Button> </Button>
{isKisVerified && ( <Button
<Button variant="outline"
variant="outline" onClick={handleRevoke}
onClick={handleRevoke} disabled={isRevoking || !verifiedCredentials}
disabled={isRevoking} className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200" >
> {isRevoking ? (
{isRevoking ? ( "해제 중"
"해제 중" ) : (
) : ( <span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5"> <Unlink2 className="h-3.5 w-3.5" />
<Unlink2 className="h-3.5 w-3.5" /> ( )
</span>
</span> )}
)} </Button>
</Button>
)}
</div> </div>
), ),
status: ( status: (

View File

@@ -49,7 +49,7 @@ export function SettingsCard({
className, className,
)} )}
> >
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand-300/70 to-transparent dark:via-brand-600/60" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-brand-300/70 to-transparent dark:via-brand-600/60" />
<div className="flex flex-1 flex-col p-5 sm:p-6"> <div className="flex flex-1 flex-col p-5 sm:p-6">
{/* ========== CARD HEADER ========== */} {/* ========== CARD HEADER ========== */}

View File

@@ -31,7 +31,8 @@ export function SettingsContainer() {
return ( return (
<section className="mx-auto flex w-full max-w-[1400px] flex-col gap-6 px-4 py-4 md:px-8 md:py-8"> <section className="mx-auto flex w-full max-w-[1400px] flex-col gap-6 px-4 py-4 md:px-8 md:py-8">
{/* ========== SETTINGS OVERVIEW ========== */} {/* ========== SETTINGS OVERVIEW ========== */}
<article className="rounded-2xl border border-brand-200 bg-gradient-to-br from-brand-50/80 via-background to-background p-5 dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/10 dark:to-background md:p-6">
<article className="rounded-2xl border border-brand-200 bg-linear-to-br from-brand-50/80 via-background to-background p-5 dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/10 dark:to-background md:p-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div className="space-y-3"> <div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground"> <h1 className="text-2xl font-semibold tracking-tight text-foreground">
@@ -43,8 +44,8 @@ export function SettingsContainer() {
</p> </p>
<div className="rounded-xl border border-brand-200/70 bg-brand-50/70 p-3 dark:border-brand-800/60 dark:bg-brand-900/20"> <div className="rounded-xl border border-brand-200/70 bg-brand-50/70 p-3 dark:border-brand-800/60 dark:bg-brand-900/20">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-200"> <p className="text-xs font-semibold text-brand-700 dark:text-brand-200">
순서: 1) {"->"} 2) {"->"} 3) 순서: 1) {"->"} 2) {"->"} 3)
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { type FormEvent, useCallback, useEffect, useState } from "react"; import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
@@ -14,6 +14,7 @@ import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocke
import { useStockOverview } from "@/features/trade/hooks/useStockOverview"; import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice"; import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel"; import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import type { import type {
DashboardStockOrderBookResponse, DashboardStockOrderBookResponse,
DashboardStockSearchItem, DashboardStockSearchItem,
@@ -27,9 +28,10 @@ import type {
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다. * @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/ */
export function TradeContainer() { export function TradeContainer() {
const searchParams = useSearchParams(); const router = useRouter();
const symbolParam = searchParams.get("symbol"); const consumePendingTarget = useTradeNavigationStore(
const nameParam = searchParams.get("name"); (state) => state.consumePendingTarget,
);
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신) // [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] = const [realtimeOrderBook, setRealtimeOrderBook] =
@@ -60,28 +62,47 @@ export function TradeContainer() {
useStockOverview(); useStockOverview();
/** /**
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드 * [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다. * 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다.
*/ */
useEffect(() => { useEffect(() => {
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) { if (typeof window === "undefined") return;
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행 if (!window.location.search) return;
if (selectedStock?.symbol !== symbolParam) { router.replace("/trade");
setKeyword(nameParam || symbolParam); }, [router]);
appendSearchHistory({
symbol: symbolParam, /**
name: nameParam || symbolParam, * [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다.
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨 * @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview
}); */
loadOverview(symbolParam, verifiedCredentials); useEffect(() => {
} if (!isKisVerified || !verifiedCredentials || !_hasHydrated) {
return;
} }
const pendingTarget = consumePendingTarget();
if (!pendingTarget) return;
if (selectedStock?.symbol === pendingTarget.symbol) {
return;
}
setKeyword(pendingTarget.name || pendingTarget.symbol);
appendSearchHistory({
symbol: pendingTarget.symbol,
name: pendingTarget.name || pendingTarget.symbol,
market: pendingTarget.market,
});
loadOverview(
pendingTarget.symbol,
verifiedCredentials,
pendingTarget.market,
);
}, [ }, [
symbolParam,
nameParam,
isKisVerified, isKisVerified,
verifiedCredentials, verifiedCredentials,
_hasHydrated, _hasHydrated,
consumePendingTarget,
selectedStock?.symbol, selectedStock?.symbol,
loadOverview, loadOverview,
setKeyword, setKeyword,

View File

@@ -6,7 +6,7 @@ import type {
DashboardStockSearchItem, DashboardStockSearchItem,
} from "@/features/trade/types/trade.types"; } from "@/features/trade/types/trade.types";
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1"; const SEARCH_HISTORY_STORAGE_KEY = "joorine:stock-search-history:v1";
const SEARCH_HISTORY_LIMIT = 12; const SEARCH_HISTORY_LIMIT = 12;
interface StoredSearchHistory { interface StoredSearchHistory {
@@ -39,7 +39,10 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
version: 1, version: 1,
items, items,
}; };
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); window.localStorage.setItem(
SEARCH_HISTORY_STORAGE_KEY,
JSON.stringify(payload),
);
} }
/** /**
@@ -50,14 +53,16 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
export function useStockSearch() { export function useStockSearch() {
// ========== SEARCH STATE ========== // ========== SEARCH STATE ==========
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]); const [searchResults, setSearchResults] = useState<
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);
// ========== SEARCH HISTORY STATE ========== // ========== SEARCH HISTORY STATE ==========
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>( const [searchHistory, setSearchHistory] = useState<
() => readSearchHistory(), DashboardStockSearchHistoryItem[]
); >(() => readSearchHistory());
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러 // 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -142,7 +147,9 @@ export function useStockSearch() {
*/ */
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => { const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
setSearchHistory((prev) => { setSearchHistory((prev) => {
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol); const deduped = prev.filter(
(historyItem) => historyItem.symbol !== item.symbol,
);
const nextItems: DashboardStockSearchHistoryItem[] = [ const nextItems: DashboardStockSearchHistoryItem[] = [
{ ...item, savedAt: Date.now() }, { ...item, savedAt: Date.now() },
...deduped, ...deduped,

View File

@@ -0,0 +1,56 @@
"use client";
import { create } from "zustand";
import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types";
/**
* @file features/trade/store/use-trade-navigation-store.ts
* @description 대시보드 -> 트레이드 이동 시 URL 쿼리 없이 종목 선택 상태를 1회 전달합니다.
*/
export interface TradeNavigationTarget {
symbol: string;
name: string;
market: DashboardStockSearchItem["market"];
requestedAt: number;
}
interface TradeNavigationStoreState {
pendingTarget: TradeNavigationTarget | null;
}
interface TradeNavigationStoreActions {
setPendingTarget: (target: Omit<TradeNavigationTarget, "requestedAt">) => void;
consumePendingTarget: () => TradeNavigationTarget | null;
clearPendingTarget: () => void;
}
/**
* @description 트레이드 화면 진입 시 사용할 종목 이동 상태 store
* @remarks UI 흐름: Dashboard 종목 클릭 -> setPendingTarget -> /trade 이동 -> TradeContainer consumePendingTarget -> 종목 로드
* @see features/dashboard/components/StockDetailPreview.tsx setPendingTarget 호출
* @see features/trade/components/TradeContainer.tsx consumePendingTarget 호출
*/
export const useTradeNavigationStore = create<
TradeNavigationStoreState & TradeNavigationStoreActions
>()((set, get) => ({
pendingTarget: null,
setPendingTarget: (target) =>
set({
pendingTarget: {
...target,
requestedAt: Date.now(),
},
}),
consumePendingTarget: () => {
const target = get().pendingTarget;
if (!target) return null;
set({ pendingTarget: null });
return target;
},
clearPendingTarget: () => set({ pendingTarget: null }),
}));

View File

@@ -33,14 +33,6 @@ export function normalizeTradingEnv(value?: string): KisTradingEnv {
return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock"; return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock";
} }
/**
* 현재 거래 환경을 반환합니다.
* @returns real | mock
*/
export function getKisTradingEnv() {
return normalizeTradingEnv(process.env.KIS_TRADING_ENV);
}
/** /**
* KIS 웹소켓 URL을 반환합니다. * KIS 웹소켓 URL을 반환합니다.
* @param tradingEnvInput 거래 모드(real/mock) * @param tradingEnvInput 거래 모드(real/mock)
@@ -50,10 +42,10 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
const tradingEnv = normalizeTradingEnv(tradingEnvInput); const tradingEnv = normalizeTradingEnv(tradingEnvInput);
if (tradingEnv === "real") { if (tradingEnv === "real") {
return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL; return DEFAULT_KIS_REAL_WS_URL;
} }
return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL; return DEFAULT_KIS_MOCK_WS_URL;
} }
/** /**
@@ -62,14 +54,7 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) {
* @returns 사용 가능 여부 * @returns 사용 가능 여부
*/ */
export function hasKisConfig(input?: KisCredentialInput) { export function hasKisConfig(input?: KisCredentialInput) {
if (input?.appKey && input?.appSecret) return true; return Boolean(input?.appKey?.trim() && input?.appSecret?.trim());
const env = getKisTradingEnv();
if (env === "real") {
return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL);
}
return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK);
} }
/** /**
@@ -78,7 +63,7 @@ export function hasKisConfig(input?: KisCredentialInput) {
* @returns tradingEnv/appKey/appSecret/baseUrl * @returns tradingEnv/appKey/appSecret/baseUrl
*/ */
export function getKisConfig(input?: KisCredentialInput): KisConfig { export function getKisConfig(input?: KisCredentialInput): KisConfig {
if (input?.appKey && input?.appSecret) { if (input?.appKey?.trim() && input?.appSecret?.trim()) {
const tradingEnv = normalizeTradingEnv(input.tradingEnv); const tradingEnv = normalizeTradingEnv(input.tradingEnv);
const baseUrl = const baseUrl =
input.baseUrl ?? input.baseUrl ??
@@ -86,37 +71,13 @@ export function getKisConfig(input?: KisCredentialInput): KisConfig {
return { return {
tradingEnv, tradingEnv,
appKey: input.appKey, appKey: input.appKey.trim(),
appSecret: input.appSecret, appSecret: input.appSecret.trim(),
baseUrl, baseUrl,
}; };
} }
const tradingEnv = getKisTradingEnv(); throw new Error(
"KIS API 키가 없습니다. 설정 화면에서 앱 키와 앱 시크릿을 먼저 입력해 주세요.",
if (tradingEnv === "real") { );
const appKey = process.env.KIS_APP_KEY_REAL;
const appSecret = process.env.KIS_APP_SECRET_REAL;
const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL;
if (!appKey || !appSecret) {
throw new Error(
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
);
}
return { tradingEnv, appKey, appSecret, baseUrl };
}
const appKey = process.env.KIS_APP_KEY_MOCK;
const appSecret = process.env.KIS_APP_SECRET_MOCK;
const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL;
if (!appKey || !appSecret) {
throw new Error(
"KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.",
);
}
return { tradingEnv, appKey, appSecret, baseUrl };
} }

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisCredentialInput } from "@/lib/kis/config"; import type { KisConfig, KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config";
/** /**
@@ -11,8 +11,9 @@ import { getKisConfig } from "@/lib/kis/config";
interface KisTokenResponse { interface KisTokenResponse {
access_token?: string; access_token?: string;
access_token_token_expired?: string; access_token_token_expired?: string;
token_type?: string;
access_token_expired?: string; access_token_expired?: string;
expires_in?: number; expires_in?: number | string;
msg1?: string; msg1?: string;
msg_cd?: string; msg_cd?: string;
error?: string; error?: string;
@@ -33,16 +34,39 @@ interface KisRevokeResponse {
const tokenCacheMap = new Map<string, KisTokenCache>(); const tokenCacheMap = new Map<string, KisTokenCache>();
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>(); const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
const TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
const KIS_TOKEN_ISSUE_PATH = "/oauth2/tokenP";
const KIS_TOKEN_REVOKE_PATH = "/oauth2/revokeP";
const KIS_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
const KIS_TOKEN_TYPE_BEARER = "bearer";
/**
* @description 토큰 캐시 식별용 해시를 생성합니다.
* @param value 해시 입력값
* @returns sha256 hex 문자열
* @see getTokenCacheKey 앱키 원문을 직접 저장하지 않기 위한 보조 함수
*/
function hashKey(value: string) { function hashKey(value: string) {
return createHash("sha256").update(value).digest("hex"); return createHash("sha256").update(value).digest("hex");
} }
/**
* @description 거래환경+앱키 기준 토큰 캐시 키를 생성합니다.
* @param credentials 사용자 입력 KIS 인증정보
* @returns 캐시 키
* @see getKisAccessToken 토큰 캐시 조회/갱신 키로 사용
*/
function getTokenCacheKey(credentials?: KisCredentialInput) { function getTokenCacheKey(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials); const config = getKisConfig(credentials);
return `${config.tradingEnv}:${hashKey(config.appKey)}`; return `${config.tradingEnv}:${hashKey(config.appKey)}`;
} }
/**
* @description 토큰 응답 문자열을 안전하게 JSON 파싱합니다.
* @param rawText 응답 원문
* @returns 파싱된 토큰 응답 객체
* @see issueKisToken 토큰 발급 응답 파싱 단계
*/
function tryParseTokenResponse(rawText: string): KisTokenResponse { function tryParseTokenResponse(rawText: string): KisTokenResponse {
try { try {
return JSON.parse(rawText) as KisTokenResponse; return JSON.parse(rawText) as KisTokenResponse;
@@ -53,6 +77,12 @@ function tryParseTokenResponse(rawText: string): KisTokenResponse {
} }
} }
/**
* @description 토큰 폐기 응답 문자열을 안전하게 JSON 파싱합니다.
* @param rawText 응답 원문
* @returns 파싱된 토큰 폐기 응답 객체
* @see revokeKisAccessToken 토큰 폐기 응답 파싱 단계
*/
function tryParseRevokeResponse(rawText: string): KisRevokeResponse { function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try { try {
return JSON.parse(rawText) as KisRevokeResponse; return JSON.parse(rawText) as KisRevokeResponse;
@@ -63,19 +93,62 @@ function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
} }
} }
function parseTokenExpiryText(value?: string) { /**
if (!value) return null; * @description number 또는 숫자 문자열을 안전하게 숫자로 변환합니다.
* @param value 원본 값
* @returns 숫자값 또는 null
* @see resolveTokenExpiry expires_in 파싱 단계
*/
function parseNumericSeconds(value?: number | string) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const normalized = value.includes("T") ? value : value.replace(" ", "T"); if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
/**
* @description KIS 만료일시 문자열을 epoch(ms)로 변환합니다.
* @param value 만료일시 문자열 (예: 2023-12-22 08:16:59)
* @returns epoch(ms) 또는 null
* @see resolveTokenExpiry access_token_token_expired 파싱 단계
*/
function parseTokenExpiryText(value?: string) {
if (!value?.trim()) return null;
const trimmed = value.trim();
// 명세 샘플("YYYY-MM-DD HH:mm:ss")은 한국시간(KST)으로 해석해 UTC 서버에서도 오차를 줄입니다.
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
const parsedKst = Date.parse(`${trimmed.replace(" ", "T")}+09:00`);
if (!Number.isNaN(parsedKst)) {
return parsedKst;
}
}
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
const parsed = Date.parse(normalized); const parsed = Date.parse(normalized);
if (Number.isNaN(parsed)) return null; if (Number.isNaN(parsed)) return null;
return parsed; return parsed;
} }
/**
* @description 토큰 만료시각을 계산합니다.
* @param payload KIS 토큰 발급 응답
* @returns 만료시각 epoch(ms)
* @see issueKisToken 토큰 캐시 저장 만료시간 계산
*/
function resolveTokenExpiry(payload: KisTokenResponse) { function resolveTokenExpiry(payload: KisTokenResponse) {
if (typeof payload.expires_in === "number" && payload.expires_in > 0) { const expiresInSeconds = parseNumericSeconds(payload.expires_in);
return Date.now() + payload.expires_in * 1000; if (expiresInSeconds && expiresInSeconds > 0) {
return Date.now() + expiresInSeconds * 1000;
} }
const absoluteExpiry = const absoluteExpiry =
@@ -87,7 +160,7 @@ function resolveTokenExpiry(payload: KisTokenResponse) {
} }
// 예외 상황 기본값: 23시간 // 예외 상황 기본값: 23시간
return Date.now() + 23 * 60 * 60 * 1000; return Date.now() + TOKEN_DEFAULT_TTL_MS;
} }
/** /**
@@ -111,24 +184,58 @@ function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요."; return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
} }
/**
* @description 토큰 발급/폐기 공통 헤더를 생성합니다.
* @returns OAuth 요청 헤더
* @see issueKisToken 접근토큰발급(P) 호출
* @see revokeKisAccessToken 접근토큰폐기(P) 호출
*/
function buildOauthHeaders(): HeadersInit {
return {
"content-type": "application/json; charset=utf-8",
accept: "application/json, text/plain, */*",
};
}
/**
* @description 접근토큰발급(P) 요청 바디를 생성합니다.
* @param config KIS 설정
* @returns tokenP 요청 바디
* @see 접근토큰발급(P)[인증-001].xlsx grant_type/appkey/appsecret 명세
*/
function buildTokenIssueBody(config: KisConfig) {
return {
grant_type: KIS_GRANT_TYPE_CLIENT_CREDENTIALS,
appkey: config.appKey,
appsecret: config.appSecret,
};
}
/**
* @description 토큰 응답 실패 상세 메시지를 조합합니다.
* @param payload KIS 토큰 응답
* @returns 상세 메시지
* @see issueKisToken 토큰 발급 실패 에러 메시지 구성
*/
function buildTokenIssueDetail(payload: KisTokenResponse) {
return [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
.filter(Boolean)
.join(" / ");
}
/** /**
* @description KIS 액세스 토큰을 발급합니다. * @description KIS 액세스 토큰을 발급합니다.
* @see app/api/kis/validate/route.ts * @see app/api/kis/validate/route.ts
* @see C:/Users/이지훈/Downloads/접근토큰발급(P)[인증-001].xlsx 접근토큰발급(P) 명세
*/ */
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> { async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
const config = getKisConfig(credentials); const config = getKisConfig(credentials);
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`; const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`;
const response = await fetch(tokenUrl, { const response = await fetch(tokenUrl, {
method: "POST", method: "POST",
headers: { headers: buildOauthHeaders(),
"content-type": "application/json; charset=utf-8", body: JSON.stringify(buildTokenIssueBody(config)),
},
body: JSON.stringify({
grant_type: "client_credentials",
appkey: config.appKey,
appsecret: config.appSecret,
}),
cache: "no-store", cache: "no-store",
}); });
@@ -136,9 +243,7 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
const payload = tryParseTokenResponse(rawText); const payload = tryParseTokenResponse(rawText);
if (!response.ok || !payload.access_token) { if (!response.ok || !payload.access_token) {
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd] const detail = buildTokenIssueDetail(payload);
.filter(Boolean)
.join(" / ");
const hint = buildTokenIssueHint(detail, config.tradingEnv); const hint = buildTokenIssueHint(detail, config.tradingEnv);
throw new Error( throw new Error(
@@ -148,6 +253,13 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
); );
} }
const tokenType = (payload.token_type ?? "").toLowerCase();
if (tokenType && tokenType !== KIS_TOKEN_TYPE_BEARER) {
throw new Error(
`KIS 토큰 발급 응답 검증 실패 (${config.tradingEnv}): token_type=${payload.token_type}`,
);
}
return { return {
token: payload.access_token, token: payload.access_token,
expiresAt: resolveTokenExpiry(payload), expiresAt: resolveTokenExpiry(payload),
@@ -192,11 +304,9 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
const cacheKey = getTokenCacheKey(credentials); const cacheKey = getTokenCacheKey(credentials);
const token = await getKisAccessToken(credentials); const token = await getKisAccessToken(credentials);
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, { const response = await fetch(`${config.baseUrl}${KIS_TOKEN_REVOKE_PATH}`, {
method: "POST", method: "POST",
headers: { headers: buildOauthHeaders(),
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ body: JSON.stringify({
appkey: config.appKey, appkey: config.appKey,
appsecret: config.appSecret, appsecret: config.appSecret,