Files
auto-trade/features/dashboard/components/MarketHubSection.tsx

349 lines
12 KiB
TypeScript

import {
AlertCircle,
BarChart3,
Flame,
Newspaper,
RefreshCcw,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { ComponentType } from "react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import type {
DashboardMarketHubResponse,
DashboardMarketRankItem,
} from "@/features/dashboard/types/dashboard.types";
import {
formatCurrency,
formatSignedCurrency,
formatSignedPercent,
getChangeToneClass,
} from "@/features/dashboard/utils/dashboard-format";
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
import { cn } from "@/lib/utils";
interface MarketHubSectionProps {
marketHub: DashboardMarketHubResponse | null;
isLoading: boolean;
error: string | null;
onRetry?: () => void;
}
/**
* @description 시장 탭의 급등/인기/뉴스 요약 섹션입니다.
* @remarks UI 흐름: DashboardContainer -> MarketHubSection -> 급등/인기/뉴스 카드 렌더링
*/
export function MarketHubSection({
marketHub,
isLoading,
error,
onRetry,
}: MarketHubSectionProps) {
const router = useRouter();
const setPendingTarget = useTradeNavigationStore(
(state) => state.setPendingTarget,
);
const gainers = marketHub?.gainers ?? [];
const losers = marketHub?.losers ?? [];
const popularByVolume = marketHub?.popularByVolume ?? [];
const popularByValue = marketHub?.popularByValue ?? [];
const news = marketHub?.news ?? [];
const warnings = marketHub?.warnings ?? [];
const pulse = marketHub?.pulse;
const navigateToTrade = (item: DashboardMarketRankItem) => {
setPendingTarget({
symbol: item.symbol,
name: item.name,
market: item.market,
});
router.push("/trade");
};
return (
<div className="grid gap-3">
<Card className="border-brand-200/80 bg-linear-to-br from-brand-100/60 via-brand-50/20 to-background shadow-sm dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
<TrendingUp className="h-4 w-4" />
</CardTitle>
<CardDescription>
/, , .
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<PulseMetric label="급등주" value={`${pulse?.gainersCount ?? 0}`} tone="up" />
<PulseMetric label="급락주" value={`${pulse?.losersCount ?? 0}`} tone="down" />
<PulseMetric
label="인기종목(거래량)"
value={`${pulse?.popularByVolumeCount ?? 0}`}
tone="neutral"
/>
<PulseMetric
label="거래대금 상위"
value={`${pulse?.popularByValueCount ?? 0}`}
tone="neutral"
/>
<PulseMetric label="주요 뉴스" value={`${pulse?.newsCount ?? 0}`} tone="brand" />
</div>
{warnings.length > 0 && (
<div className="flex flex-wrap gap-2">
{warnings.map((warning) => (
<Badge
key={warning}
variant="outline"
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
>
<AlertCircle className="h-3 w-3" />
{warning}
</Badge>
))}
</div>
)}
{isLoading && !marketHub && (
<p className="text-sm text-muted-foreground"> .</p>
)}
{error && (
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</p>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
</Button>
) : null}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
<RankListCard
title="급등주식"
description="전일 대비 상승률 상위 종목"
icon={Flame}
items={gainers}
tone="up"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="급락주식"
description="전일 대비 하락률 상위 종목"
icon={TrendingDown}
items={losers}
tone="down"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="인기종목"
description="거래량 상위 종목"
icon={BarChart3}
items={popularByVolume}
tone="neutral"
onSelectItem={navigateToTrade}
secondaryLabel="거래량"
secondaryValue={(item) => `${formatCurrency(item.volume)}`}
/>
<RankListCard
title="거래대금 상위"
description="거래대금 상위 종목"
icon={TrendingUp}
items={popularByValue}
tone="brand"
onSelectItem={navigateToTrade}
secondaryLabel="거래대금"
secondaryValue={(item) => `${formatCurrency(item.tradingValue)}`}
/>
</div>
<Card className="border-brand-200/70 bg-background/90 dark:border-brand-800/45">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Newspaper className="h-4 w-4 text-brand-600 dark:text-brand-400" />
</CardTitle>
<CardDescription>
/ .
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[220px] pr-3">
{news.length === 0 ? (
<p className="text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-2">
{news.map((item) => (
<article
key={item.id}
className="rounded-xl border border-border/70 bg-linear-to-br from-background to-brand-50/30 px-3 py-2 dark:from-background dark:to-brand-950/20"
>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="mt-1 text-xs text-muted-foreground">
{item.source} · {item.publishedAt}
</p>
{item.symbols.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{item.symbols.slice(0, 3).map((symbol) => (
<Badge
key={`${item.id}-${symbol}`}
variant="outline"
className="text-[10px]"
>
{symbol}
</Badge>
))}
</div>
)}
</article>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
);
}
function PulseMetric({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "up" | "down" | "neutral" | "brand";
}) {
const toneClass =
tone === "up"
? "border-red-200/70 bg-red-50/70 dark:border-red-900/40 dark:bg-red-950/20"
: tone === "down"
? "border-blue-200/70 bg-blue-50/70 dark:border-blue-900/40 dark:bg-blue-950/20"
: tone === "brand"
? "border-brand-200/70 bg-brand-50/70 dark:border-brand-700/60 dark:bg-brand-900/30"
: "border-border/70 bg-background/80";
return (
<div className={cn("rounded-xl border p-3", toneClass)}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
function RankListCard({
title,
description,
icon: Icon,
items,
tone,
onSelectItem,
secondaryLabel,
secondaryValue,
}: {
title: string;
description: string;
icon: ComponentType<{ className?: string }>;
items: DashboardMarketRankItem[];
tone: "up" | "down" | "neutral" | "brand";
onSelectItem: (item: DashboardMarketRankItem) => void;
secondaryLabel: string;
secondaryValue: (item: DashboardMarketRankItem) => string;
}) {
const toneClass =
tone === "up"
? "border-red-200/70 bg-red-50/35 dark:border-red-900/35 dark:bg-red-950/15"
: tone === "down"
? "border-blue-200/70 bg-blue-50/35 dark:border-blue-900/35 dark:bg-blue-950/15"
: tone === "brand"
? "border-brand-200/70 bg-brand-50/35 dark:border-brand-800/50 dark:bg-brand-900/20"
: "border-border/70 bg-background/90";
return (
<Card className={cn("overflow-hidden shadow-sm", toneClass)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Icon className="h-4 w-4 text-brand-600 dark:text-brand-400" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[220px] pr-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-2">
{items.map((item) => {
const toneClass = getChangeToneClass(item.change);
return (
<div
key={`${title}-${item.symbol}-${item.rank}`}
className="rounded-xl border border-border/70 bg-background/80 px-3 py-2 shadow-sm"
>
<button
type="button"
onClick={() => onSelectItem(item)}
className="w-full text-left hover:opacity-90"
title={`${item.name} 거래 화면으로 이동`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">{item.name}</p>
<p className={cn("text-xs font-medium", toneClass)}>
{formatSignedPercent(item.changeRate)}
</p>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
#{item.rank} · {item.symbol} · {item.market}
</p>
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">
{formatCurrency(item.price)}
</span>
<span className={cn("font-medium", toneClass)}>
{formatSignedCurrency(item.change)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{secondaryLabel} {secondaryValue(item)}
</p>
</button>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}