보안 점검 및 대시보드 문구 수정

This commit is contained in:
2026-02-13 15:44:41 +09:00
parent 1ac907cd27
commit 7c194d7452
31 changed files with 686 additions and 438 deletions

View File

@@ -4,34 +4,13 @@
*/
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowRight,
ShieldCheck,
Sparkles,
TrendingUp,
Zap,
} from "lucide-react";
import { ArrowRight, Sparkles } from "lucide-react";
import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
interface ValuePoint {
value: string;
label: string;
detail: string;
}
interface FeatureItem {
icon: LucideIcon;
eyebrow: string;
title: string;
description: string;
}
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
interface StartStep {
step: string;
@@ -39,311 +18,195 @@ interface StartStep {
description: string;
}
const VALUE_POINTS: ValuePoint[] = [
{
value: "3분",
label: "초기 세팅 시간",
detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.",
},
{
value: "24시간",
label: "실시간 시장 관제",
detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.",
},
{
value: "0원",
label: "가입 시작 비용",
detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.",
},
];
const FEATURE_ITEMS: FeatureItem[] = [
{
icon: ShieldCheck,
eyebrow: "Risk Guard",
title: "손실 방어를 먼저 설계합니다",
description:
"진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.",
},
{
icon: TrendingUp,
eyebrow: "Data Momentum",
title: "데이터로 타점을 좁힙니다",
description:
"실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.",
},
{
icon: Zap,
eyebrow: "Auto Execution",
title: "기회가 오면 즉시 자동 실행합니다",
description:
"규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.",
},
];
const START_STEPS: StartStep[] = [
{
step: "STEP 01",
title: "계정 연결",
description: "안내에 따라 거래 계정을 안전하게 연결합니다.",
step: "01",
title: "1분이면 충분해요",
description:
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.",
},
{
step: "STEP 02",
title: "성향 선택",
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.",
step: "02",
title: "내 스타일대로 골라보세요",
description:
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.",
},
{
step: "STEP 03",
title: "자동 실행 시작",
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.",
step: "03",
title: "이제 일상을 즐기세요",
description:
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.",
},
];
/**
* 홈 메인 랜딩 페이지
* @returns 랜딩 UI
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
*/
export default async function HomePage() {
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기";
const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN;
const secondaryCtaLabel = user ? "실시간 상태 확인하기" : "기존 계정으로 로그인";
const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기";
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} />
<main className="relative isolate flex-1 overflow-hidden pt-16">
{/* ========== SHADER BACKGROUND SECTION ========== */}
<ShaderBackground opacity={1} className="-z-20" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
<main className="relative isolate flex-1">
{/* ========== BACKGROUND ========== */}
<ShaderBackground opacity={0.6} className="-z-20" />
<div
aria-hidden="true"
className="pointer-events-none absolute -left-40 top-40 -z-10 h-96 w-96 rounded-full bg-brand-500/20 blur-3xl"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-24 top-72 -z-10 h-[26rem] w-[26rem] rounded-full bg-brand-300/20 blur-3xl"
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
/>
{/* ========== HERO SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-14 pt-14 md:pt-24">
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
<div className="lg:col-span-7">
<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" />
</span>
<section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
<div className="flex flex-col items-center text-center">
<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">
<Sparkles className="h-3.5 w-3.5" />
, JOORIN-E
</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">
,
<br />
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
</span>
</h1>
<p className="mt-5 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/80 duration-700 md:text-lg">
.
<br />
, , .
</p>
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
<Button
asChild
size="lg"
className="group h-12 rounded-full bg-linear-to-r from-brand-500 via-brand-400 to-brand-600 px-8 text-base font-semibold text-white shadow-2xl shadow-brand-800/45 [background-size:200%_200%] animate-gradient-x hover:brightness-110"
>
<Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
className="h-12 rounded-full border-white/35 bg-black/30 px-8 text-base text-white backdrop-blur-md hover:bg-white/10 hover:text-white"
>
<Link href={secondaryCtaHref}>{secondaryCtaLabel}</Link>
</Button>
</div>
<div className="mt-4 flex flex-wrap gap-3 text-xs text-white/70">
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
3
</span>
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
</div>
</div>
<div className="relative lg:col-span-5">
<div className="absolute -inset-px rounded-3xl bg-linear-to-br from-brand-300/50 via-brand-600/0 to-brand-600/60 blur-lg" />
<div className="relative overflow-hidden rounded-3xl border border-white/15 bg-black/35 p-6 shadow-[0_30px_90px_-45px_rgba(0,0,0,0.85)] backdrop-blur-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<p className="text-xs font-semibold tracking-wide text-brand-200">LIVE STRATEGY STATUS</p>
<p className="mt-1 text-lg font-semibold text-white"> </p>
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand-500/25 px-3 py-1 text-xs font-semibold text-brand-100">
<Activity className="h-3.5 w-3.5" />
</span>
</div>
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
<div className="mt-5 grid gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> + </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> , </p>
</div>
</div>
<div className="mt-5 flex items-center gap-2 text-xs text-brand-100/90">
<span className="inline-block h-2 w-2 rounded-full bg-brand-300" />
.
</div>
</div>
</div>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{VALUE_POINTS.map((point) => (
<div
key={point.label}
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-colors hover:bg-white/10"
>
<p className="text-2xl font-black text-brand-100">{point.value}</p>
<p className="mt-2 text-sm font-semibold text-white">{point.label}</p>
<p className="mt-2 text-xs leading-relaxed text-white/70">{point.detail}</p>
</div>
))}
</div>
</section>
{/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
,
<br />
<span className="text-brand-200"> </span>
</h2>
</div>
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
.
</span>
</h1>
<div className="mt-8 grid gap-6 md:grid-cols-3">
{FEATURE_ITEMS.map((feature) => {
const FeatureIcon = feature.icon;
<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 className="hidden md:block" />
24 .
</p>
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 className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
<Button
asChild
size="lg"
className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
>
<Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
</div>
</div>
</section>
{/* ========== START STEP SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<br />
<span className="text-brand-200"> 3 </span>
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-white/70">
, .
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
<AnimatedBrandTone />
</section>
{/* ========== SIMPLE STEPS SECTION ========== */}
<section className="container mx-auto max-w-5xl px-4 py-24">
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
<div className="flex-1 text-center md:text-left">
<h2 className="text-3xl font-black md:text-5xl">
<br />
<span className="text-brand-300"> 3 .</span>
</h2>
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
JOORIN-E가 .
<br />
&apos;&apos; .
</p>
</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) => (
<div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5">
<p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p>
<p className="mt-2 text-lg font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p>
<div
key={item.step}
className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
>
<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>
</section>
{/* ========== CTA SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-20">
<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
aria-hidden="true"
className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl"
/>
<div
aria-hidden="true"
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl"
/>
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left">
{/* 보안 안심 문구 (사용자 요청 반영) */}
<div 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="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 className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
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>
<p className="text-sm font-semibold text-brand-100"> </p>
<h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl">
,
<h3 className="text-lg font-bold text-brand-100">
, ?
</h3>
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
<strong className="text-brand-200">
, .
</strong>
<br />
</h2>
<p className="mt-3 text-sm text-white/75 md:text-base">
.
JOORIN-E는 API
.
<br className="hidden md:block" />
()
,
<br className="hidden md:block" />
.
</p>
</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
asChild
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}>
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
</Button>
</div>
<p className="mt-8 text-sm text-white/30">
© 2026 POPUP STUDIO. All rights reserved.
</p>
</div>
</section>
</main>

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

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

View File

@@ -26,7 +26,7 @@ export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialIn
}
/**
* @description 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다.
* @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
* @param headers 요청 헤더
* @returns 계좌번호 파트(8 + 2) 또는 null
* @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 headerAccountProductCode = headers.get("x-kis-account-product-code");
const envAccountNo = process.env.KIS_ACCOUNT_NO;
const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
return (
parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
parseKisAccountParts(envAccountNo, envAccountProductCode)
);
return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
@@ -20,6 +21,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
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 symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
KisCredentialInput,
hasKisConfig,
@@ -20,6 +21,11 @@ import {
*/
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 symbol = (searchParams.get("symbol") ?? "").trim();

View File

@@ -2,6 +2,7 @@ import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } 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 { NextRequest, NextResponse } from "next/server";
import {
@@ -20,6 +21,11 @@ import {
* @returns 대시보드 상세 모델
*/
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 symbol = (searchParams.get("symbol") ?? "").trim();

View File

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

View File

@@ -5,6 +5,7 @@ import {
validateKisCredentialInput,
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
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);
if (invalidMessage) {
return NextResponse.json(

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
import { kisGet } from "@/lib/kis/client";
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 함수
*/
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 = {};
try {
@@ -51,7 +68,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
ok: false,
tradingEnv: "mock",
tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },

View File

@@ -5,6 +5,7 @@ import {
validateKisCredentialInput,
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
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);
if (invalidMessage) {
return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
@@ -20,6 +21,18 @@ export async function POST(request: NextRequest) {
const credentials = await parseKisCredentialRequest(request);
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);
if (invalidMessage) {
return NextResponse.json(

View File

@@ -33,9 +33,9 @@ const outfit = Outfit({
});
export const metadata: Metadata = {
title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
description:
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
};
/**