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

193 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-02-13 12:17:35 +09:00
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
import { RefreshCcw } from "lucide-react";
2026-02-12 14:20:07 +09:00
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
2026-02-13 12:17:35 +09:00
import { Button } from "@/components/ui/button";
2026-02-12 14:20:07 +09:00
import {
formatCurrency,
formatSignedCurrency,
formatSignedPercent,
} from "@/features/dashboard/utils/dashboard-format";
import { cn } from "@/lib/utils";
2026-02-13 12:17:35 +09:00
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
2026-02-12 14:20:07 +09:00
interface MarketSummaryProps {
items: DashboardMarketIndexItem[];
isLoading: boolean;
error: string | null;
2026-02-13 12:17:35 +09:00
warning?: string | null;
isRealtimePending?: boolean;
onRetry?: () => void;
2026-02-12 14:20:07 +09:00
}
/**
* @description / .
* @see features/dashboard/components/DashboardContainer.tsx .
*/
2026-02-13 12:17:35 +09:00
export function MarketSummary({
items,
isLoading,
error,
warning = null,
isRealtimePending = false,
onRetry,
}: MarketSummaryProps) {
2026-02-12 14:20:07 +09:00
return (
2026-02-13 12:17:35 +09:00
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</div>
<CardDescription> / .</CardDescription>
2026-02-12 14:20:07 +09:00
</CardHeader>
2026-02-13 12:17:35 +09:00
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{/* ========== LOADING STATE ========== */}
2026-02-12 14:20:07 +09:00
{isLoading && items.length === 0 && (
2026-02-13 12:17:35 +09:00
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
...
</div>
2026-02-12 14:20:07 +09:00
)}
2026-02-13 12:17:35 +09:00
{/* ========== REALTIME PENDING STATE ========== */}
{isRealtimePending && items.length === 0 && !isLoading && !error && (
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
.
</div>
2026-02-12 14:20:07 +09:00
)}
2026-02-13 12:17:35 +09:00
{/* ========== ERROR/WARNING STATE ========== */}
{error && (
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
<p> .</p>
<p className="mt-1 text-xs opacity-80">
{toCompactErrorMessage(error)}
</p>
<p className="mt-1 text-xs opacity-80">
API / .
</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>
)}
{!error && warning && (
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
{warning}
</div>
)}
2026-02-12 14:20:07 +09:00
2026-02-13 12:17:35 +09:00
{/* ========== INDEX CARDS ========== */}
{items.map((item) => (
<IndexItem key={item.code} item={item} />
))}
2026-02-12 14:20:07 +09:00
{!isLoading && items.length === 0 && !error && (
2026-02-13 12:17:35 +09:00
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
.
</div>
2026-02-12 14:20:07 +09:00
)}
</CardContent>
</Card>
);
}
2026-02-13 12:17:35 +09:00
/**
* @description .
* @param error
* @returns
* @see features/dashboard/components/MarketSummary.tsx
*/
function toCompactErrorMessage(error: string) {
const normalized = error.replaceAll(/\s+/g, " ").trim();
if (!normalized) return "잠시 후 다시 시도해 주세요.";
if (normalized.length <= 120) return normalized;
return `${normalized.slice(0, 120)}...`;
}
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
const isUp = item.change > 0;
const isDown = item.change < 0;
const toneClass = isUp
? "text-red-600 dark:text-red-400"
: isDown
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground";
const bgClass = isUp
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
: isDown
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
: "bg-muted/50 border-border/50";
const flash = usePriceFlash(item.price, item.code);
return (
<div
className={cn(
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
bgClass,
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
{item.market}
</span>
{isUp ? (
<TrendingUp className="h-4 w-4 text-red-500/70" />
) : isDown ? (
<TrendingDown className="h-4 w-4 text-blue-500/70" />
) : null}
</div>
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
{formatCurrency(item.price)}
{/* Flash Indicator */}
{flash && (
<div
key={flash.id} // Force re-render for animation restart using state ID
className={cn(
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
flash.type === "up" ? "text-red-500" : "text-blue-500",
)}
>
{flash.type === "up" ? "+" : ""}
{flash.val.toFixed(2)}
</div>
)}
</div>
<div
className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
toneClass,
)}
>
<span>{formatSignedCurrency(item.change)}</span>
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
{formatSignedPercent(item.changeRate)}
</span>
</div>
</div>
);
}