보안 점검 및 대시보드 문구 수정
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
@@ -45,6 +46,9 @@ export function StockDetailPreview({
|
||||
totalAmount,
|
||||
}: StockDetailPreviewProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
|
||||
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
|
||||
const currentPrice = holding?.currentPrice ?? 0;
|
||||
@@ -92,11 +96,14 @@ export function StockDetailPreview({
|
||||
<CardDescription className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setPendingTarget({
|
||||
symbol: holding.symbol,
|
||||
name: holding.name,
|
||||
market: holding.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Logo({
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex items-center gap-2 select-none", className)}
|
||||
aria-label="Jurini Logo"
|
||||
aria-label="JOORIN-E Logo"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function Header({
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>시작하기</Link>
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>회원가입</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -162,23 +162,21 @@ export function KisAuthForm() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isKisVerified && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking}
|
||||
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
{isRevoking ? (
|
||||
"해제 중"
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Unlink2 className="h-3.5 w-3.5" />
|
||||
연결 해제
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRevoke}
|
||||
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 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 ? (
|
||||
"해제 중"
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Unlink2 className="h-3.5 w-3.5" />
|
||||
연결 해제(토큰 폐기)
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
status: (
|
||||
|
||||
@@ -49,7 +49,7 @@ export function SettingsCard({
|
||||
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">
|
||||
{/* ========== CARD HEADER ========== */}
|
||||
|
||||
@@ -31,7 +31,8 @@ export function SettingsContainer() {
|
||||
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">
|
||||
{/* ========== 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="space-y-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
@@ -43,8 +44,8 @@ export function SettingsContainer() {
|
||||
</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">
|
||||
<p className="text-xs font-semibold text-brand-700 dark:text-brand-200">
|
||||
진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3)
|
||||
거래 화면 사용
|
||||
진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) 거래
|
||||
화면 사용
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
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 { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
|
||||
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import type {
|
||||
DashboardStockOrderBookResponse,
|
||||
DashboardStockSearchItem,
|
||||
@@ -27,9 +28,10 @@ import type {
|
||||
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||
*/
|
||||
export function TradeContainer() {
|
||||
const searchParams = useSearchParams();
|
||||
const symbolParam = searchParams.get("symbol");
|
||||
const nameParam = searchParams.get("name");
|
||||
const router = useRouter();
|
||||
const consumePendingTarget = useTradeNavigationStore(
|
||||
(state) => state.consumePendingTarget,
|
||||
);
|
||||
|
||||
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
@@ -60,28 +62,47 @@ export function TradeContainer() {
|
||||
useStockOverview();
|
||||
|
||||
/**
|
||||
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
|
||||
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
|
||||
* [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다.
|
||||
* 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
|
||||
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
|
||||
if (selectedStock?.symbol !== symbolParam) {
|
||||
setKeyword(nameParam || symbolParam);
|
||||
appendSearchHistory({
|
||||
symbol: symbolParam,
|
||||
name: nameParam || symbolParam,
|
||||
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
|
||||
});
|
||||
loadOverview(symbolParam, verifiedCredentials);
|
||||
}
|
||||
if (typeof window === "undefined") return;
|
||||
if (!window.location.search) return;
|
||||
router.replace("/trade");
|
||||
}, [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({
|
||||
symbol: pendingTarget.symbol,
|
||||
name: pendingTarget.name || pendingTarget.symbol,
|
||||
market: pendingTarget.market,
|
||||
});
|
||||
loadOverview(
|
||||
pendingTarget.symbol,
|
||||
verifiedCredentials,
|
||||
pendingTarget.market,
|
||||
);
|
||||
}, [
|
||||
symbolParam,
|
||||
nameParam,
|
||||
isKisVerified,
|
||||
verifiedCredentials,
|
||||
_hasHydrated,
|
||||
consumePendingTarget,
|
||||
selectedStock?.symbol,
|
||||
loadOverview,
|
||||
setKeyword,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DashboardStockSearchItem,
|
||||
} 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;
|
||||
|
||||
interface StoredSearchHistory {
|
||||
@@ -39,7 +39,10 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||
version: 1,
|
||||
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() {
|
||||
// ========== SEARCH STATE ==========
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
DashboardStockSearchItem[]
|
||||
>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// ========== SEARCH HISTORY STATE ==========
|
||||
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
|
||||
() => readSearchHistory(),
|
||||
);
|
||||
const [searchHistory, setSearchHistory] = useState<
|
||||
DashboardStockSearchHistoryItem[]
|
||||
>(() => readSearchHistory());
|
||||
|
||||
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -142,7 +147,9 @@ export function useStockSearch() {
|
||||
*/
|
||||
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
|
||||
setSearchHistory((prev) => {
|
||||
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
|
||||
const deduped = prev.filter(
|
||||
(historyItem) => historyItem.symbol !== item.symbol,
|
||||
);
|
||||
const nextItems: DashboardStockSearchHistoryItem[] = [
|
||||
{ ...item, savedAt: Date.now() },
|
||||
...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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user