feat(market): add 24h crypto sparkline chart with CoinGecko history and resilient fallback
This commit is contained in:
@@ -163,7 +163,7 @@ If empty, the app uses the local default (`/api/rates`).
|
||||
- Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`.
|
||||
- `GET /api/market?code=BTC`
|
||||
- Returns CoinGecko-based market snapshot for a crypto asset.
|
||||
- Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `updatedAt`.
|
||||
- Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistory24h`, `updatedAt`.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { CurrencyIcon } from "@/components/converter/currency-icon";
|
||||
import { PriceSparkline } from "@/components/converter/price-sparkline";
|
||||
import { CurrencySelect } from "@/components/converter/currency-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -550,6 +551,12 @@ export function ConverterCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<PriceSparkline
|
||||
points={marketData?.priceHistory24h ?? []}
|
||||
isLoading={isMarketLoading && !marketData}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
||||
|
||||
138
components/converter/price-sparkline.tsx
Normal file
138
components/converter/price-sparkline.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PriceSparklinePoint {
|
||||
timestamp: string;
|
||||
priceUsd: number;
|
||||
}
|
||||
|
||||
interface PriceSparklineProps {
|
||||
points: PriceSparklinePoint[];
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PriceSparkline({
|
||||
points,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: PriceSparklineProps) {
|
||||
const gradientId = useId();
|
||||
|
||||
const chart = useMemo(() => {
|
||||
const sorted = [...points]
|
||||
.filter((point) => Number.isFinite(point.priceUsd) && point.priceUsd > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
if (sorted.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = 320;
|
||||
const height = 84;
|
||||
const padding = 6;
|
||||
|
||||
const prices = sorted.map((point) => point.priceUsd);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const range = Math.max(maxPrice - minPrice, maxPrice * 0.001, 0.00000001);
|
||||
|
||||
const coordinates = sorted.map((point, index) => {
|
||||
const x =
|
||||
padding + (index / (sorted.length - 1)) * (width - padding * 2);
|
||||
const y =
|
||||
padding +
|
||||
(1 - (point.priceUsd - minPrice) / range) * (height - padding * 2);
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePath = coordinates
|
||||
.map((point, index) =>
|
||||
`${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`,
|
||||
)
|
||||
.join(" ");
|
||||
|
||||
const firstPoint = coordinates[0];
|
||||
const lastPoint = coordinates[coordinates.length - 1];
|
||||
|
||||
const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} L ${firstPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} Z`;
|
||||
const isUptrend = prices[prices.length - 1] >= prices[0];
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
linePath,
|
||||
areaPath,
|
||||
isUptrend,
|
||||
};
|
||||
}, [points]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border/60 bg-background/60 p-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Price (24h)
|
||||
</p>
|
||||
|
||||
{chart ? (
|
||||
<div className="mt-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${chart.width} ${chart.height}`}
|
||||
className="h-20 w-full"
|
||||
role="img"
|
||||
aria-label="Price trend over the last 24 hours"
|
||||
preserveAspectRatio="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={chart.isUptrend ? "#34d399" : "#fb7185"}
|
||||
stopOpacity="0.26"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={chart.isUptrend ? "#34d399" : "#fb7185"}
|
||||
stopOpacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
|
||||
<path
|
||||
d={chart.linePath}
|
||||
fill="none"
|
||||
stroke={chart.isUptrend ? "#34d399" : "#fb7185"}
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="bevel"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex h-20 items-center justify-center rounded-md border border-dashed border-border/60 text-xs text-muted-foreground">
|
||||
{isLoading ? "Loading 24h price history..." : "24h price history unavailable"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,11 @@ export interface CryptoMarketSnapshot {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CryptoPricePoint {
|
||||
timestamp: string;
|
||||
priceUsd: number;
|
||||
}
|
||||
|
||||
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
||||
|
||||
function buildCoinGeckoHeaders(): HeadersInit {
|
||||
@@ -91,6 +96,43 @@ export async function fetchCryptoMarketSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCryptoPriceHistory24h(
|
||||
providerId: string,
|
||||
): Promise<CryptoPricePoint[]> {
|
||||
const url = `${COINGECKO_BASE_URL}/coins/${encodeURIComponent(
|
||||
providerId,
|
||||
)}/market_chart?vs_currency=usd&days=1`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 60 },
|
||||
headers: buildCoinGeckoHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load crypto price history (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
prices?: Array<[number, number]>;
|
||||
};
|
||||
|
||||
const points = (payload.prices ?? [])
|
||||
.filter(
|
||||
(entry): entry is [number, number] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
Number.isFinite(entry[0]) &&
|
||||
Number.isFinite(entry[1]) &&
|
||||
entry[1] > 0,
|
||||
)
|
||||
.map(([timestamp, priceUsd]) => ({
|
||||
timestamp: new Date(timestamp).toISOString(),
|
||||
priceUsd,
|
||||
}));
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export async function fetchCryptoData(): Promise<CryptoRateResult> {
|
||||
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
|
||||
(id): id is string => Boolean(id)
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface CryptoMarketResponse {
|
||||
change24hPct: number | null;
|
||||
marketCapUsd: number | null;
|
||||
volume24hUsd: number | null;
|
||||
priceHistory24h: Array<{
|
||||
timestamp: string;
|
||||
priceUsd: number;
|
||||
}>;
|
||||
updatedAt: string;
|
||||
source: string;
|
||||
}
|
||||
@@ -18,6 +22,15 @@ const marketResponseSchema = z.object({
|
||||
change24hPct: z.number().nullable(),
|
||||
marketCapUsd: z.number().nullable(),
|
||||
volume24hUsd: z.number().nullable(),
|
||||
priceHistory24h: z
|
||||
.array(
|
||||
z.object({
|
||||
timestamp: z.string(),
|
||||
priceUsd: z.number().positive(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
updatedAt: z.string(),
|
||||
source: z.string(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchCryptoMarketSnapshot } from "@/lib/api/crypto";
|
||||
import {
|
||||
fetchCryptoMarketSnapshot,
|
||||
fetchCryptoPriceHistory24h,
|
||||
} from "@/lib/api/crypto";
|
||||
|
||||
export interface CachedCryptoMarketSnapshot {
|
||||
code: string;
|
||||
@@ -7,6 +10,10 @@ export interface CachedCryptoMarketSnapshot {
|
||||
change24hPct: number | null;
|
||||
marketCapUsd: number | null;
|
||||
volume24hUsd: number | null;
|
||||
priceHistory24h: Array<{
|
||||
timestamp: string;
|
||||
priceUsd: number;
|
||||
}>;
|
||||
updatedAt: string;
|
||||
source: "CoinGecko";
|
||||
}
|
||||
@@ -44,6 +51,13 @@ export async function getCryptoMarketWithCache(params: {
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const snapshot = await fetchCryptoMarketSnapshot(params.providerId);
|
||||
let history: Array<{ timestamp: string; priceUsd: number }> = [];
|
||||
|
||||
try {
|
||||
history = await fetchCryptoPriceHistory24h(params.providerId);
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
|
||||
const normalized: CachedCryptoMarketSnapshot = {
|
||||
code: params.code,
|
||||
@@ -52,6 +66,7 @@ export async function getCryptoMarketWithCache(params: {
|
||||
change24hPct: snapshot.change24hPct,
|
||||
marketCapUsd: snapshot.marketCapUsd,
|
||||
volume24hUsd: snapshot.volume24hUsd,
|
||||
priceHistory24h: history,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
source: "CoinGecko",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user