Compare commits
1 Commits
e5a518b211
...
871f864dce
| Author | SHA1 | Date | |
|---|---|---|---|
| 871f864dce |
@@ -1 +1 @@
|
|||||||
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000}}
|
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Header } from "@/features/layout/components/header";
|
import { Header } from "@/features/layout/components/header";
|
||||||
import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
|
import { Sidebar } from "@/features/layout/components/sidebar";
|
||||||
import { createClient } from "@/utils/supabase/server";
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
@@ -17,9 +17,8 @@ export default async function MainLayout({
|
|||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
<div className="flex flex-1 pt-16">
|
<div className="flex flex-1 pt-16">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
|
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
<MobileBottomNav />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
|
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
|
||||||
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
|
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
|
||||||
@@ -13,7 +11,6 @@ import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
|
|||||||
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
|
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
|
||||||
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
|
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
|
||||||
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
|
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
|
||||||
import { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice";
|
|
||||||
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
|
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
|
||||||
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
|
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
|
||||||
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
|
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
|
||||||
@@ -33,11 +30,6 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export function DashboardContainer() {
|
export function DashboardContainer() {
|
||||||
const skipNextAutoSearchRef = useRef(false);
|
const skipNextAutoSearchRef = useRef(false);
|
||||||
const hasInitializedAuthPanelRef = useRef(false);
|
|
||||||
|
|
||||||
// 모바일에서는 초기 진입 시 API 키 패널을 접어서 본문(차트/호가)을 먼저 보이게 합니다.
|
|
||||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
|
||||||
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
|
|
||||||
|
|
||||||
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
@@ -96,47 +88,6 @@ export function DashboardContainer() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Price Calculation Logic (Hook)
|
|
||||||
const {
|
|
||||||
currentPrice,
|
|
||||||
change,
|
|
||||||
changeRate,
|
|
||||||
prevClose: referencePrice,
|
|
||||||
} = useCurrentPrice({
|
|
||||||
stock: selectedStock,
|
|
||||||
latestTick,
|
|
||||||
orderBook,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
|
||||||
|
|
||||||
const applyViewportMode = (matches: boolean) => {
|
|
||||||
setIsMobileViewport(matches);
|
|
||||||
|
|
||||||
// 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침.
|
|
||||||
if (!hasInitializedAuthPanelRef.current) {
|
|
||||||
setIsAuthPanelExpanded(!matches);
|
|
||||||
hasInitializedAuthPanelRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다.
|
|
||||||
if (!matches) {
|
|
||||||
setIsAuthPanelExpanded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
applyViewportMode(mediaQuery.matches);
|
|
||||||
|
|
||||||
const onViewportChange = (event: MediaQueryListEvent) => {
|
|
||||||
applyViewportMode(event.matches);
|
|
||||||
};
|
|
||||||
mediaQuery.addEventListener("change", onViewportChange);
|
|
||||||
|
|
||||||
return () => mediaQuery.removeEventListener("change", onViewportChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (skipNextAutoSearchRef.current) {
|
if (skipNextAutoSearchRef.current) {
|
||||||
skipNextAutoSearchRef.current = false;
|
skipNextAutoSearchRef.current = false;
|
||||||
@@ -161,6 +112,29 @@ export function DashboardContainer() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
|
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
|
||||||
|
|
||||||
|
// Price Calculation Logic
|
||||||
|
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
|
||||||
|
let currentPrice = selectedStock?.currentPrice;
|
||||||
|
let change = selectedStock?.change;
|
||||||
|
let changeRate = selectedStock?.changeRate;
|
||||||
|
|
||||||
|
if (latestTick) {
|
||||||
|
currentPrice = latestTick.price;
|
||||||
|
change = latestTick.change;
|
||||||
|
changeRate = latestTick.changeRate;
|
||||||
|
} else if (orderBook?.levels[0]?.askPrice) {
|
||||||
|
// Fallback: Use Best Ask Price as proxy for current price
|
||||||
|
const askPrice = orderBook.levels[0].askPrice;
|
||||||
|
if (askPrice > 0) {
|
||||||
|
currentPrice = askPrice;
|
||||||
|
// Recalculate change/rate based on prevClose
|
||||||
|
if (selectedStock && selectedStock.prevClose > 0) {
|
||||||
|
change = currentPrice - selectedStock.prevClose;
|
||||||
|
changeRate = (change / selectedStock.prevClose) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSearchSubmit(e: React.FormEvent) {
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isKisVerified || !verifiedCredentials) {
|
if (!isKisVerified || !verifiedCredentials) {
|
||||||
@@ -193,7 +167,7 @@ export function DashboardContainer() {
|
|||||||
<div className="relative h-full flex flex-col">
|
<div className="relative h-full flex flex-col">
|
||||||
{/* ========== AUTH STATUS ========== */}
|
{/* ========== AUTH STATUS ========== */}
|
||||||
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
|
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
|
<div className="flex items-center justify-between px-4 py-2 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold">KIS API 연결 상태:</span>
|
<span className="font-semibold">KIS API 연결 상태:</span>
|
||||||
{isKisVerified ? (
|
{isKisVerified ? (
|
||||||
@@ -209,40 +183,9 @@ export function DashboardContainer() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
|
|
||||||
className={cn(
|
|
||||||
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
|
|
||||||
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100",
|
|
||||||
!isAuthPanelExpanded && isMobileViewport && "ring-2 ring-brand-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAuthPanelExpanded ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="h-3.5 w-3.5" />
|
|
||||||
API 설정 접기
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
API 설정 펼치기
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100">
|
||||||
className={cn(
|
|
||||||
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
|
|
||||||
isAuthPanelExpanded
|
|
||||||
? "max-h-[560px] opacity-100"
|
|
||||||
: "max-h-0 opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-4 border-t bg-background">
|
<div className="p-4 border-t bg-background">
|
||||||
<KisAuthForm />
|
<KisAuthForm />
|
||||||
</div>
|
</div>
|
||||||
@@ -320,7 +263,7 @@ export function DashboardContainer() {
|
|||||||
orderBook={
|
orderBook={
|
||||||
<OrderBook
|
<OrderBook
|
||||||
symbol={selectedStock?.symbol}
|
symbol={selectedStock?.symbol}
|
||||||
referencePrice={referencePrice}
|
referencePrice={selectedStock?.prevClose}
|
||||||
currentPrice={currentPrice}
|
currentPrice={currentPrice}
|
||||||
latestTick={latestTick}
|
latestTick={latestTick}
|
||||||
recentTicks={recentTradeTicks}
|
recentTicks={recentTradeTicks}
|
||||||
|
|||||||
@@ -31,46 +31,28 @@ export function StockHeader({
|
|||||||
: "text-foreground";
|
: "text-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 sm:px-4 sm:py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
{/* ========== STOCK SUMMARY ========== */}
|
{/* Left: Stock Info */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="truncate text-lg font-bold leading-tight sm:text-xl">
|
<h1 className="text-xl font-bold">{stock.name}</h1>
|
||||||
{stock.name}
|
<span className="text-sm text-muted-foreground">
|
||||||
</h1>
|
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground sm:text-sm">
|
|
||||||
{stock.symbol}/{stock.market}
|
{stock.symbol}/{stock.market}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("shrink-0 text-right", colorClass)}>
|
<Separator orientation="vertical" className="h-6" />
|
||||||
<span className="block text-2xl font-bold tracking-tight">{price}</span>
|
|
||||||
<span className="text-xs font-medium sm:text-sm">
|
<div className={cn("flex items-end gap-2", colorClass)}>
|
||||||
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
|
<span className="text-2xl font-bold tracking-tight">{price}</span>
|
||||||
|
<span className="text-sm font-medium mb-1">
|
||||||
|
{changeRate}% <span className="text-xs ml-1">{change}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== STATS ========== */}
|
{/* Right: 24h Stats */}
|
||||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
|
<div className="hidden md:flex items-center gap-6 text-sm">
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
|
||||||
<p className="text-[11px] text-muted-foreground">고가</p>
|
|
||||||
<p className="font-medium text-red-500">{high || "--"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
|
||||||
<p className="text-[11px] text-muted-foreground">저가</p>
|
|
||||||
<p className="font-medium text-blue-500">{low || "--"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
|
||||||
<p className="text-[11px] text-muted-foreground">거래량(24H)</p>
|
|
||||||
<p className="font-medium">{volume || "--"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mt-2 md:hidden" />
|
|
||||||
|
|
||||||
{/* ========== DESKTOP STATS ========== */}
|
|
||||||
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
|
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="text-muted-foreground text-xs">고가</span>
|
<span className="text-muted-foreground text-xs">고가</span>
|
||||||
<span className="font-medium text-red-500">{high || "--"}</span>
|
<span className="font-medium text-red-500">{high || "--"}</span>
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ export function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col bg-background",
|
"flex h-[calc(100vh-64px)] flex-col overflow-hidden",
|
||||||
// Mobile: Scrollable page height
|
|
||||||
"min-h-[calc(100vh-64px)]",
|
|
||||||
// Desktop: Fixed height, no window scroll
|
|
||||||
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -33,37 +29,22 @@ export function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Main Content Area */}
|
{/* 2. Main Content Area */}
|
||||||
<div
|
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row">
|
||||||
className={cn(
|
|
||||||
"flex flex-1 flex-col",
|
|
||||||
// Mobile: Allow content to flow naturally with spacing
|
|
||||||
"overflow-visible pb-4 gap-4",
|
|
||||||
// Desktop: Internal scrolling, horizontal layout, no page spacing
|
|
||||||
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Left Column: Chart & Info */}
|
{/* Left Column: Chart & Info */}
|
||||||
<div
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border xl:border-r">
|
||||||
className={cn(
|
|
||||||
"flex flex-col border-border",
|
|
||||||
// Mobile: Fixed height for chart to ensure visibility
|
|
||||||
"h-[320px] flex-none border-b sm:h-[360px]",
|
|
||||||
// Desktop: Fill remaining space, remove bottom border, add right border
|
|
||||||
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-h-0">{chart}</div>
|
<div className="flex-1 min-h-0">{chart}</div>
|
||||||
{/* Future: Transaction History / Market Depth can go here */}
|
{/* Future: Transaction History / Market Depth can go here */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Order Book & Order Form */}
|
{/* Right Column: Order Book & Order Form */}
|
||||||
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] xl:pr-2 2xl:w-[500px]">
|
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] 2xl:w-[500px]">
|
||||||
{/* Top: Order Book (Hoga) */}
|
{/* Top: Order Book (Hoga) */}
|
||||||
<div className="h-[390px] flex-none overflow-hidden border-t border-border sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
|
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b">
|
||||||
{orderBook}
|
{orderBook}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: Order Form */}
|
{/* Bottom: Order Form */}
|
||||||
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
|
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]">
|
||||||
{orderForm}
|
{orderForm}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
const isMarketDataAvailable = !!stock;
|
const isMarketDataAvailable = !!stock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full border-l border-border bg-background p-3 sm:p-4">
|
<div className="h-full bg-background p-4 border-l border-border">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="w-full h-full flex flex-col"
|
className="w-full h-full flex flex-col"
|
||||||
>
|
>
|
||||||
<TabsList className="mb-3 grid w-full grid-cols-2 sm:mb-4">
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="buy"
|
value="buy"
|
||||||
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
|
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
|
||||||
@@ -105,7 +105,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="buy"
|
value="buy"
|
||||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
|
||||||
>
|
>
|
||||||
<OrderInputs
|
<OrderInputs
|
||||||
type="buy"
|
type="buy"
|
||||||
@@ -122,7 +122,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto h-11 w-full bg-red-600 text-base hover:bg-red-700 sm:h-12 sm:text-lg"
|
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12"
|
||||||
disabled={isLoading || !isMarketDataAvailable}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("buy")}
|
onClick={() => handleOrder("buy")}
|
||||||
>
|
>
|
||||||
@@ -132,7 +132,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="sell"
|
value="sell"
|
||||||
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
|
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
|
||||||
>
|
>
|
||||||
<OrderInputs
|
<OrderInputs
|
||||||
type="sell"
|
type="sell"
|
||||||
@@ -149,7 +149,7 @@ export function OrderForm({ stock }: OrderFormProps) {
|
|||||||
<PercentButtons onSelect={setPercent} />
|
<PercentButtons onSelect={setPercent} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto h-11 w-full bg-blue-600 text-base hover:bg-blue-700 sm:h-12 sm:text-lg"
|
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12"
|
||||||
disabled={isLoading || !isMarketDataAvailable}
|
disabled={isLoading || !isMarketDataAvailable}
|
||||||
onClick={() => handleOrder("sell")}
|
onClick={() => handleOrder("sell")}
|
||||||
>
|
>
|
||||||
@@ -183,7 +183,7 @@ function OrderInputs({
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>주문가능</span>
|
<span>주문가능</span>
|
||||||
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
<span>- {type === "buy" ? "KRW" : "주"}</span>
|
||||||
@@ -195,8 +195,8 @@ function OrderInputs({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 gap-2 items-center">
|
||||||
<span className="text-xs font-medium sm:text-sm">
|
<span className="text-sm font-medium">
|
||||||
{type === "buy" ? "매수가격" : "매도가격"}
|
{type === "buy" ? "매수가격" : "매도가격"}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -207,8 +207,8 @@ function OrderInputs({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 gap-2 items-center">
|
||||||
<span className="text-xs font-medium sm:text-sm">주문수량</span>
|
<span className="text-sm font-medium">주문수량</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3 text-right font-mono"
|
className="col-span-3 text-right font-mono"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
@@ -217,8 +217,8 @@ function OrderInputs({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-2">
|
<div className="grid grid-cols-4 gap-2 items-center">
|
||||||
<span className="text-xs font-medium sm:text-sm">주문총액</span>
|
<span className="text-sm font-medium">주문총액</span>
|
||||||
<Input
|
<Input
|
||||||
className="col-span-3 text-right font-mono bg-muted/50"
|
className="col-span-3 text-right font-mono bg-muted/50"
|
||||||
value={totalPrice.toLocaleString()}
|
value={totalPrice.toLocaleString()}
|
||||||
|
|||||||
@@ -164,17 +164,17 @@ export function OrderBook({
|
|||||||
|
|
||||||
{/* ── 일반호가 탭 ── */}
|
{/* ── 일반호가 탭 ── */}
|
||||||
<TabsContent value="normal" className="min-h-0 flex-1">
|
<TabsContent value="normal" className="min-h-0 flex-1">
|
||||||
<div className="block h-full min-h-0 border-t xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
|
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
|
||||||
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
|
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
|
||||||
{/* 호가 테이블 */}
|
{/* 호가 테이블 */}
|
||||||
<div className="min-h-0 xl:border-r">
|
<div className="min-h-0 border-r">
|
||||||
<BookHeader />
|
<BookHeader />
|
||||||
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
|
<ScrollArea className="h-[calc(100%-32px)]">
|
||||||
{/* 매도호가 */}
|
{/* 매도호가 */}
|
||||||
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
|
||||||
|
|
||||||
{/* 중앙 바: 현재 체결가 */}
|
{/* 중앙 바: 현재 체결가 */}
|
||||||
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
|
<div className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30">
|
||||||
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
|
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
|
||||||
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
{totalAsk > 0 ? fmt(totalAsk) : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -210,22 +210,18 @@ export function OrderBook({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 요약 패널 */}
|
{/* 우측 요약 패널 */}
|
||||||
<div className="hidden xl:block">
|
<SummaryPanel
|
||||||
<SummaryPanel
|
orderBook={orderBook}
|
||||||
orderBook={orderBook}
|
latestTick={latestTick}
|
||||||
latestTick={latestTick}
|
spread={spread}
|
||||||
spread={spread}
|
imbalance={imbalance}
|
||||||
imbalance={imbalance}
|
totalAsk={totalAsk}
|
||||||
totalAsk={totalAsk}
|
totalBid={totalBid}
|
||||||
totalBid={totalBid}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 체결 목록 */}
|
{/* 체결 목록 */}
|
||||||
<div className="hidden xl:block">
|
<TradeTape ticks={recentTicks} />
|
||||||
<TradeTape ticks={recentTicks} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -289,7 +285,7 @@ function BookSideRows({
|
|||||||
<div
|
<div
|
||||||
key={`${side}-${row.price}-${i}`}
|
key={`${side}-${row.price}-${i}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs",
|
"grid h-8 grid-cols-3 border-b border-border/40 text-xs",
|
||||||
row.isHighlighted &&
|
row.isHighlighted &&
|
||||||
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
|
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
|
||||||
)}
|
)}
|
||||||
@@ -372,7 +368,7 @@ function SummaryPanel({
|
|||||||
totalBid: number;
|
totalBid: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 border-l bg-muted/15 p-2 text-[11px]">
|
<div className="border-l bg-muted/15 p-2 text-[11px]">
|
||||||
<Row
|
<Row
|
||||||
label="실시간"
|
label="실시간"
|
||||||
value={orderBook ? "연결됨" : "끊김"}
|
value={orderBook ? "연결됨" : "끊김"}
|
||||||
@@ -444,11 +440,11 @@ function Row({
|
|||||||
tone?: "ask" | "bid";
|
tone?: "ask" | "bid";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1">
|
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1">
|
||||||
<span className="min-w-0 truncate text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 font-medium tabular-nums",
|
"font-medium tabular-nums",
|
||||||
tone === "ask" && "text-red-600",
|
tone === "ask" && "text-red-600",
|
||||||
tone === "bid" && "text-blue-600",
|
tone === "bid" && "text-blue-600",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import type {
|
|
||||||
DashboardRealtimeTradeTick,
|
|
||||||
DashboardStockItem,
|
|
||||||
DashboardStockOrderBookResponse,
|
|
||||||
} from "@/features/dashboard/types/dashboard.types";
|
|
||||||
|
|
||||||
interface UseCurrentPriceParams {
|
|
||||||
stock?: DashboardStockItem | null;
|
|
||||||
latestTick: DashboardRealtimeTradeTick | null;
|
|
||||||
orderBook: DashboardStockOrderBookResponse | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCurrentPrice({
|
|
||||||
stock,
|
|
||||||
latestTick,
|
|
||||||
orderBook,
|
|
||||||
}: UseCurrentPriceParams) {
|
|
||||||
return useMemo(() => {
|
|
||||||
let currentPrice = stock?.currentPrice ?? 0;
|
|
||||||
let change = stock?.change ?? 0;
|
|
||||||
let changeRate = stock?.changeRate ?? 0;
|
|
||||||
const prevClose = stock?.prevClose ?? 0;
|
|
||||||
|
|
||||||
// 1. Priority: Realtime Tick (Trade WS)
|
|
||||||
if (latestTick?.price && latestTick.price > 0) {
|
|
||||||
currentPrice = latestTick.price;
|
|
||||||
change = latestTick.change;
|
|
||||||
changeRate = latestTick.changeRate;
|
|
||||||
}
|
|
||||||
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
|
|
||||||
else if (
|
|
||||||
orderBook?.levels[0]?.askPrice &&
|
|
||||||
orderBook.levels[0].askPrice > 0
|
|
||||||
) {
|
|
||||||
const askPrice = orderBook.levels[0].askPrice;
|
|
||||||
currentPrice = askPrice;
|
|
||||||
|
|
||||||
// Recalculate change/rate based on prevClose
|
|
||||||
if (prevClose > 0) {
|
|
||||||
change = currentPrice - prevClose;
|
|
||||||
changeRate = (change / prevClose) * 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentPrice,
|
|
||||||
change,
|
|
||||||
changeRate,
|
|
||||||
prevClose,
|
|
||||||
};
|
|
||||||
}, [stock, latestTick, orderBook]);
|
|
||||||
}
|
|
||||||
@@ -9,54 +9,43 @@ import { MenuItem } from "../types";
|
|||||||
const MENU_ITEMS: MenuItem[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: "대시보드",
|
title: "대시보드",
|
||||||
href: "/dashboard",
|
href: "/",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
variant: "default",
|
variant: "default",
|
||||||
matchExact: true,
|
matchExact: true,
|
||||||
showInBottomNav: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "자동매매",
|
title: "자동매매",
|
||||||
href: "/trade",
|
href: "/trade",
|
||||||
icon: BarChart2,
|
icon: BarChart2,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
badge: "LIVE",
|
|
||||||
showInBottomNav: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "자산현황",
|
title: "자산현황",
|
||||||
href: "/assets",
|
href: "/assets",
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
showInBottomNav: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "프로필",
|
title: "프로필",
|
||||||
href: "/profile",
|
href: "/profile",
|
||||||
icon: User,
|
icon: User,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
showInBottomNav: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "설정",
|
title: "설정",
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
showInBottomNav: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
|
|
||||||
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
|
|
||||||
*/
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="group/sidebar hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white px-2 py-5 transition-[width] duration-200 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-[74px] md:hover:w-64 md:focus-within:w-64">
|
<aside className="hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-64 lg:w-72">
|
||||||
{/* ========== SIDEBAR ITEMS ========== */}
|
<div className="flex flex-col space-y-1">
|
||||||
<div className="flex flex-col space-y-1.5">
|
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
const isActive = item.matchExact
|
const isActive = item.matchExact
|
||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
@@ -66,46 +55,22 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
title={item.title}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors",
|
||||||
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
|
|
||||||
isActive
|
isActive
|
||||||
? "bg-zinc-100 text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50"
|
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
|
||||||
: "text-zinc-500 dark:text-zinc-400",
|
: "text-zinc-500 dark:text-zinc-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ========== ACTIVE BAR ========== */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
|
|
||||||
isActive ? "w-1.5 bg-brand-500" : "w-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ========== ICON + DOT BADGE ========== */}
|
|
||||||
<item.icon
|
<item.icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-5 w-5 shrink-0 transition-colors",
|
"mr-3 h-5 w-5 shrink-0 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "text-zinc-900 dark:text-zinc-50"
|
? "text-zinc-900 dark:text-zinc-50"
|
||||||
: "text-zinc-400 group-hover/item:text-zinc-900 dark:text-zinc-500 dark:group-hover/item:text-zinc-50",
|
: "text-zinc-400 group-hover:text-zinc-900 dark:text-zinc-500 dark:group-hover:text-zinc-50",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{item.title}
|
||||||
{item.badge && (
|
|
||||||
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500 md:group-hover/sidebar:hidden md:group-focus-within/sidebar:hidden" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ========== LABEL (EXPAND ON HOVER/FOCUS) ========== */}
|
|
||||||
<span className="ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200 md:max-w-0 md:opacity-0 md:group-hover/sidebar:max-w-[180px] md:group-hover/sidebar:opacity-100 md:group-focus-within/sidebar:max-w-[180px] md:group-focus-within/sidebar:opacity-100">
|
|
||||||
<span className="truncate font-medium">{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
|
||||||
{item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -113,49 +78,3 @@ export function Sidebar() {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 모바일 하단 빠른 탭 네비게이션.
|
|
||||||
* @see features/layout/components/sidebar.tsx Sidebar와 같은 MENU_ITEMS를 공유해 중복 정의를 줄입니다.
|
|
||||||
*/
|
|
||||||
export function MobileBottomNav() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const bottomItems = MENU_ITEMS.filter((item) => item.showInBottomNav !== false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="모바일 빠른 메뉴"
|
|
||||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-zinc-200 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-zinc-800 dark:bg-black/95 dark:supports-backdrop-filter:bg-black/80 md:hidden"
|
|
||||||
>
|
|
||||||
{/* ========== BOTTOM NAV ITEMS ========== */}
|
|
||||||
<div className={cn("grid", bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5")}>
|
|
||||||
{bottomItems.map((item) => {
|
|
||||||
const isActive = item.matchExact
|
|
||||||
? pathname === item.href
|
|
||||||
: pathname.startsWith(item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={`bottom-${item.href}`}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
|
||||||
isActive
|
|
||||||
? "text-brand-700"
|
|
||||||
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="relative">
|
|
||||||
<item.icon className={cn("h-4 w-4", isActive && "text-brand-600")} />
|
|
||||||
{item.badge && (
|
|
||||||
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="leading-none">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ export interface MenuItem {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
variant: "default" | "ghost";
|
variant: "default" | "ghost";
|
||||||
matchExact?: boolean;
|
matchExact?: boolean;
|
||||||
badge?: string;
|
|
||||||
showInBottomNav?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [active] [ref=e1]:
|
|
||||||
- generic [ref=e2]:
|
|
||||||
- banner [ref=e3]:
|
|
||||||
- generic [ref=e4]:
|
|
||||||
- link "AutoTrade" [ref=e5] [cursor=pointer]:
|
|
||||||
- /url: /
|
|
||||||
- generic [ref=e8]: AutoTrade
|
|
||||||
- generic [ref=e9]:
|
|
||||||
- button "Toggle theme" [ref=e10]:
|
|
||||||
- img
|
|
||||||
- generic [ref=e11]: Toggle theme
|
|
||||||
- link "시작하기" [ref=e13] [cursor=pointer]:
|
|
||||||
- /url: /signup
|
|
||||||
- main [ref=e18]:
|
|
||||||
- generic [ref=e20]:
|
|
||||||
- generic [ref=e21]:
|
|
||||||
- generic [ref=e23]: 👋
|
|
||||||
- generic [ref=e24]: 환영합니다!
|
|
||||||
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
|
|
||||||
- generic [ref=e27]:
|
|
||||||
- generic [ref=e28]:
|
|
||||||
- generic [ref=e29]:
|
|
||||||
- generic [ref=e30]: 이메일
|
|
||||||
- textbox "이메일" [ref=e31]:
|
|
||||||
- /placeholder: your@email.com
|
|
||||||
- generic [ref=e32]:
|
|
||||||
- generic [ref=e33]: 비밀번호
|
|
||||||
- textbox "비밀번호" [ref=e34]:
|
|
||||||
- /placeholder: ••••••••
|
|
||||||
- generic [ref=e35]:
|
|
||||||
- generic [ref=e36]:
|
|
||||||
- checkbox "이메일 기억하기" [ref=e37]
|
|
||||||
- checkbox
|
|
||||||
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
|
|
||||||
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
|
|
||||||
- /url: /forgot-password
|
|
||||||
- button "로그인" [ref=e40]
|
|
||||||
- paragraph [ref=e41]:
|
|
||||||
- text: 계정이 없으신가요?
|
|
||||||
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
|
|
||||||
- /url: /signup
|
|
||||||
- generic [ref=e44]: 또는 소셜 로그인
|
|
||||||
- generic [ref=e45]:
|
|
||||||
- button "Google" [ref=e47]:
|
|
||||||
- img
|
|
||||||
- text: Google
|
|
||||||
- button "Kakao" [ref=e49]:
|
|
||||||
- img
|
|
||||||
- text: Kakao
|
|
||||||
- generic [ref=e50]:
|
|
||||||
- img [ref=e52]
|
|
||||||
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
|
|
||||||
- img [ref=e101]
|
|
||||||
- region "Notifications alt+T"
|
|
||||||
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
|
|
||||||
- img [ref=e155]
|
|
||||||
- alert [ref=e158]
|
|
||||||
```
|
|
||||||
@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
|
|||||||
<div id='root'></div>
|
<div id='root'></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script>
|
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "passed",
|
||||||
"failedTests": [
|
"failedTests": []
|
||||||
"2746d92cb07c1216e72c-59289721e6ad6cc3d2d4"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Page snapshot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- generic [active] [ref=e1]:
|
|
||||||
- generic [ref=e2]:
|
|
||||||
- banner [ref=e3]:
|
|
||||||
- generic [ref=e4]:
|
|
||||||
- link "AutoTrade" [ref=e5] [cursor=pointer]:
|
|
||||||
- /url: /
|
|
||||||
- generic [ref=e8]: AutoTrade
|
|
||||||
- generic [ref=e9]:
|
|
||||||
- button "Toggle theme" [ref=e10]:
|
|
||||||
- img
|
|
||||||
- generic [ref=e11]: Toggle theme
|
|
||||||
- link "시작하기" [ref=e13] [cursor=pointer]:
|
|
||||||
- /url: /signup
|
|
||||||
- main [ref=e18]:
|
|
||||||
- generic [ref=e20]:
|
|
||||||
- generic [ref=e21]:
|
|
||||||
- generic [ref=e23]: 👋
|
|
||||||
- generic [ref=e24]: 환영합니다!
|
|
||||||
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
|
|
||||||
- generic [ref=e27]:
|
|
||||||
- generic [ref=e28]:
|
|
||||||
- generic [ref=e29]:
|
|
||||||
- generic [ref=e30]: 이메일
|
|
||||||
- textbox "이메일" [ref=e31]:
|
|
||||||
- /placeholder: your@email.com
|
|
||||||
- generic [ref=e32]:
|
|
||||||
- generic [ref=e33]: 비밀번호
|
|
||||||
- textbox "비밀번호" [ref=e34]:
|
|
||||||
- /placeholder: ••••••••
|
|
||||||
- generic [ref=e35]:
|
|
||||||
- generic [ref=e36]:
|
|
||||||
- checkbox "이메일 기억하기" [ref=e37]
|
|
||||||
- checkbox
|
|
||||||
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
|
|
||||||
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
|
|
||||||
- /url: /forgot-password
|
|
||||||
- button "로그인" [ref=e40]
|
|
||||||
- paragraph [ref=e41]:
|
|
||||||
- text: 계정이 없으신가요?
|
|
||||||
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
|
|
||||||
- /url: /signup
|
|
||||||
- generic [ref=e44]: 또는 소셜 로그인
|
|
||||||
- generic [ref=e45]:
|
|
||||||
- button "Google" [ref=e47]:
|
|
||||||
- img
|
|
||||||
- text: Google
|
|
||||||
- button "Kakao" [ref=e49]:
|
|
||||||
- img
|
|
||||||
- text: Kakao
|
|
||||||
- generic [ref=e50]:
|
|
||||||
- img [ref=e52]
|
|
||||||
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
|
|
||||||
- img [ref=e101]
|
|
||||||
- region "Notifications alt+T"
|
|
||||||
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
|
|
||||||
- img [ref=e155]
|
|
||||||
- alert [ref=e158]
|
|
||||||
```
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Mobile Dashboard Layout", () => {
|
|
||||||
test.use({
|
|
||||||
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
|
|
||||||
userAgent:
|
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should verify mobile layout elements are visible and not overlapping", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// 1. 대시보드 페이지 이동
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
|
|
||||||
// 페이지 로딩 대기 (네트워크 유휴 상태 혹은 특정 요소)
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
// 2. 주요 섹션 존재 여부 확인
|
|
||||||
const chartSection = page.locator("text=차트 영역, O, H, L, C").first(); // 차트 관련 텍스트나 영역
|
|
||||||
// StockLineChart가 로드되면 차트 캔버스가 있을 것임
|
|
||||||
const chartCanvas = page.locator("canvas").first();
|
|
||||||
|
|
||||||
// 호가창 탭
|
|
||||||
const orderBookTabs = page.getByRole("tablist");
|
|
||||||
|
|
||||||
// 주문 폼 탭
|
|
||||||
const orderFormTabs = page.getByRole("tab", { name: "매수" });
|
|
||||||
|
|
||||||
// 3. 스크린샷 캡처 (디버깅용)
|
|
||||||
await page.screenshot({
|
|
||||||
path: "test-results/mobile-dashboard-before-fix.png",
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. 요소 가시성 체크 (현재 고정 높이 버그로 인해 겹치거나 잘릴 수 있음)
|
|
||||||
// 수정 전에는 이것들이 겹쳐있거나 뷰포트 밖으로 밀려나도 스크롤이 안될 수 있음.
|
|
||||||
|
|
||||||
// 체크: body에 스크롤이 생겼는지 확인 (수정 후에는 생겨야 함)
|
|
||||||
// 현재(버그 상태)는 overflow-hidden이라 스크롤이 없을 것임.
|
|
||||||
|
|
||||||
// 5. 레이아웃 검증 로직
|
|
||||||
// 차트 높이가 충분한지
|
|
||||||
const chartBox = await page
|
|
||||||
.locator(".flex-1.min-h-0")
|
|
||||||
.first()
|
|
||||||
.boundingBox();
|
|
||||||
console.log("Chart Box:", chartBox);
|
|
||||||
|
|
||||||
// 호가창이 보이는지
|
|
||||||
const orderBookBox = await page
|
|
||||||
.locator("text=일반호가")
|
|
||||||
.first()
|
|
||||||
.boundingBox();
|
|
||||||
console.log("OrderBook Box:", orderBookBox);
|
|
||||||
|
|
||||||
// 주문폼이 보이는지
|
|
||||||
const orderFormBox = await page
|
|
||||||
.locator("text=매수하기")
|
|
||||||
.first()
|
|
||||||
.boundingBox();
|
|
||||||
console.log("OrderForm Box:", orderFormBox);
|
|
||||||
|
|
||||||
// 단순 존재 여부만 통과시키고, 실제 시각적 확인은 스크린샷으로 대체
|
|
||||||
expect(page.url()).toContain("/dashboard");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Mobile Dashboard Scroll", () => {
|
|
||||||
test.use({
|
|
||||||
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
|
|
||||||
userAgent:
|
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow scrolling to access order form at the bottom", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// 1. Navigate to dashboard
|
|
||||||
await page.goto("http://localhost:3001/dashboard");
|
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
|
|
||||||
// 2. Check Top Element (Chart)
|
|
||||||
const chart = page.locator("canvas").first();
|
|
||||||
await expect(chart).toBeVisible();
|
|
||||||
|
|
||||||
// 3. Scroll to Bottom
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
await page.waitForTimeout(500); // Wait for scroll
|
|
||||||
|
|
||||||
// 4. Check Bottom Element (Order Form)
|
|
||||||
// "매수하기" button is a good indicator of the order form
|
|
||||||
const buyButton = page.getByRole("button", { name: "매수하기" });
|
|
||||||
await expect(buyButton).toBeVisible();
|
|
||||||
|
|
||||||
// 5. Verify Scroll Height is greater than Viewport Height
|
|
||||||
const scrollHeight = await page.evaluate(
|
|
||||||
() => document.documentElement.scrollHeight,
|
|
||||||
);
|
|
||||||
const viewportHeight = 844;
|
|
||||||
expect(scrollHeight).toBeGreaterThan(viewportHeight);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Scroll Height: ${scrollHeight}, Viewport Height: ${viewportHeight}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Capture screenshot at bottom
|
|
||||||
await page.screenshot({
|
|
||||||
path: "test-results/mobile-scroll-bottom.png",
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user