Compare commits
2 Commits
1ac907cd27
...
b73867c65d
| Author | SHA1 | Date | |
|---|---|---|---|
| b73867c65d | |||
| 7c194d7452 |
20
.env.example
20
.env.example
@@ -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=
|
|
||||||
|
|||||||
@@ -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 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.
|
||||||
|
|
||||||
|
## 소개문구
|
||||||
|
|
||||||
|
- 불안감을 해소하고 확신을 주는 문구
|
||||||
|
- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략
|
||||||
|
|||||||
@@ -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. 로컬 실행
|
||||||
|
|||||||
@@ -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 />
|
<br />
|
||||||
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
|
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
|
||||||
내 계좌의 성장 루틴을 만드세요
|
마음 편하게 하세요.
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</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">
|
<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">
|
||||||
주린이는 보호와 성장의 균형을 먼저 설계합니다.
|
어렵고 불안한 주식 투자, 혼자 고민하지 마세요.
|
||||||
<br />
|
<br className="hidden md:block" />
|
||||||
손실 방어 규칙, 데이터 신호, 자동 실행을 하나의 흐름으로 묶어 초보의 첫 수익 루틴을 만듭니다.
|
검증된 원칙으로 24시간 당신의 자산을 지켜드릴게요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
|
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<Link href={primaryCtaHref}>
|
<Link href={primaryCtaHref}>
|
||||||
{primaryCtaLabel}
|
{primaryCtaLabel}
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ========== FEATURE SECTION ========== */}
|
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
|
||||||
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
|
||||||
<div className="mx-auto max-w-3xl text-center">
|
<AnimatedBrandTone />
|
||||||
<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 />
|
|
||||||
<span className="text-brand-200">기준이 분명한 자동매매</span>입니다
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-6 md:grid-cols-3">
|
|
||||||
{FEATURE_ITEMS.map((feature) => {
|
|
||||||
const FeatureIcon = feature.icon;
|
|
||||||
|
|
||||||
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>
|
|
||||||
<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">
|
|
||||||
<FeatureIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<CardContent className="text-sm leading-relaxed text-white/75">
|
|
||||||
{feature.description}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ========== START STEP SECTION ========== */}
|
{/* ========== SIMPLE STEPS 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">
|
||||||
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
|
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
|
||||||
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
<div className="flex-1 text-center md:text-left">
|
||||||
<div>
|
<h2 className="text-3xl font-black md:text-5xl">
|
||||||
<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 />
|
<br />
|
||||||
<span className="text-brand-200">딱 3단계면 충분합니다</span>
|
<span className="text-brand-300">단 3단계면 끝.</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
|
||||||
<p className="max-w-sm text-sm leading-relaxed text-white/70">
|
복잡한 계산과 감시는 JOORIN-E가 대신할게요.
|
||||||
어려운 설정 화면 대신, 따라 하기 쉬운 단계로 바로 시작할 수 있게 구성했습니다.
|
<br />
|
||||||
|
당신은 가벼운 마음으로 '시작' 버튼만 누르세요.
|
||||||
</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
18
app/api/kis/_session.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
common-docs/features/trade-stock-sync.md
Normal file
42
common-docs/features/trade-stock-sync.md
Normal 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`
|
||||||
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal 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")
|
||||||
|
}
|
||||||
|
```
|
||||||
115
components/ui/animated-brand-tone.tsx
Normal file
115
components/ui/animated-brand-tone.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
“{TONE_PHRASES[index].q}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
110
features/layout/components/GlobalAlertModal.tsx
Normal file
110
features/layout/components/GlobalAlertModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
84
features/layout/hooks/use-global-alert.ts
Normal file
84
features/layout/hooks/use-global-alert.ts
Normal 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 "알림";
|
||||||
|
}
|
||||||
|
}
|
||||||
43
features/layout/stores/use-global-alert-store.ts
Normal file
43
features/layout/stores/use-global-alert-store.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
@@ -162,23 +162,21 @@ export function KisAuthForm() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isKisVerified && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRevoke}
|
onClick={handleRevoke}
|
||||||
disabled={isRevoking}
|
disabled={isRevoking || !verifiedCredentials}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{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: (
|
||||||
|
|||||||
@@ -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 ========== */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다.
|
||||||
|
* @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isKisVerified || !verifiedCredentials || !_hasHydrated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingTarget = consumePendingTarget();
|
||||||
|
if (!pendingTarget) return;
|
||||||
|
|
||||||
|
if (selectedStock?.symbol === pendingTarget.symbol) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeyword(pendingTarget.name || pendingTarget.symbol);
|
||||||
appendSearchHistory({
|
appendSearchHistory({
|
||||||
symbol: symbolParam,
|
symbol: pendingTarget.symbol,
|
||||||
name: nameParam || symbolParam,
|
name: pendingTarget.name || pendingTarget.symbol,
|
||||||
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
|
market: pendingTarget.market,
|
||||||
});
|
});
|
||||||
loadOverview(symbolParam, verifiedCredentials);
|
loadOverview(
|
||||||
}
|
pendingTarget.symbol,
|
||||||
}
|
verifiedCredentials,
|
||||||
|
pendingTarget.market,
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
symbolParam,
|
|
||||||
nameParam,
|
|
||||||
isKisVerified,
|
isKisVerified,
|
||||||
verifiedCredentials,
|
verifiedCredentials,
|
||||||
_hasHydrated,
|
_hasHydrated,
|
||||||
|
consumePendingTarget,
|
||||||
selectedStock?.symbol,
|
selectedStock?.symbol,
|
||||||
loadOverview,
|
loadOverview,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
56
features/trade/store/use-trade-navigation-store.ts
Normal file
56
features/trade/store/use-trade-navigation-store.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
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(
|
throw new Error(
|
||||||
"KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.",
|
"KIS API 키가 없습니다. 설정 화면에서 앱 키와 앱 시크릿을 먼저 입력해 주세요.",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
158
lib/kis/token.ts
158
lib/kis/token.ts
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user