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`.
|
- Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`.
|
||||||
- `GET /api/market?code=BTC`
|
- `GET /api/market?code=BTC`
|
||||||
- Returns CoinGecko-based market snapshot for a crypto asset.
|
- 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
|
## Architecture Notes
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { CurrencyIcon } from "@/components/converter/currency-icon";
|
import { CurrencyIcon } from "@/components/converter/currency-icon";
|
||||||
|
import { PriceSparkline } from "@/components/converter/price-sparkline";
|
||||||
import { CurrencySelect } from "@/components/converter/currency-select";
|
import { CurrencySelect } from "@/components/converter/currency-select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -550,6 +551,12 @@ export function ConverterCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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="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">
|
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||||
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
<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;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CryptoPricePoint {
|
||||||
|
timestamp: string;
|
||||||
|
priceUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
||||||
|
|
||||||
function buildCoinGeckoHeaders(): HeadersInit {
|
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> {
|
export async function fetchCryptoData(): Promise<CryptoRateResult> {
|
||||||
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
|
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
|
||||||
(id): id is string => Boolean(id)
|
(id): id is string => Boolean(id)
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface CryptoMarketResponse {
|
|||||||
change24hPct: number | null;
|
change24hPct: number | null;
|
||||||
marketCapUsd: number | null;
|
marketCapUsd: number | null;
|
||||||
volume24hUsd: number | null;
|
volume24hUsd: number | null;
|
||||||
|
priceHistory24h: Array<{
|
||||||
|
timestamp: string;
|
||||||
|
priceUsd: number;
|
||||||
|
}>;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +22,15 @@ const marketResponseSchema = z.object({
|
|||||||
change24hPct: z.number().nullable(),
|
change24hPct: z.number().nullable(),
|
||||||
marketCapUsd: z.number().nullable(),
|
marketCapUsd: z.number().nullable(),
|
||||||
volume24hUsd: z.number().nullable(),
|
volume24hUsd: z.number().nullable(),
|
||||||
|
priceHistory24h: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
timestamp: z.string(),
|
||||||
|
priceUsd: z.number().positive(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
source: 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 {
|
export interface CachedCryptoMarketSnapshot {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -7,6 +10,10 @@ export interface CachedCryptoMarketSnapshot {
|
|||||||
change24hPct: number | null;
|
change24hPct: number | null;
|
||||||
marketCapUsd: number | null;
|
marketCapUsd: number | null;
|
||||||
volume24hUsd: number | null;
|
volume24hUsd: number | null;
|
||||||
|
priceHistory24h: Array<{
|
||||||
|
timestamp: string;
|
||||||
|
priceUsd: number;
|
||||||
|
}>;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
source: "CoinGecko";
|
source: "CoinGecko";
|
||||||
}
|
}
|
||||||
@@ -44,6 +51,13 @@ export async function getCryptoMarketWithCache(params: {
|
|||||||
|
|
||||||
const requestPromise = (async () => {
|
const requestPromise = (async () => {
|
||||||
const snapshot = await fetchCryptoMarketSnapshot(params.providerId);
|
const snapshot = await fetchCryptoMarketSnapshot(params.providerId);
|
||||||
|
let history: Array<{ timestamp: string; priceUsd: number }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
history = await fetchCryptoPriceHistory24h(params.providerId);
|
||||||
|
} catch {
|
||||||
|
history = [];
|
||||||
|
}
|
||||||
|
|
||||||
const normalized: CachedCryptoMarketSnapshot = {
|
const normalized: CachedCryptoMarketSnapshot = {
|
||||||
code: params.code,
|
code: params.code,
|
||||||
@@ -52,6 +66,7 @@ export async function getCryptoMarketWithCache(params: {
|
|||||||
change24hPct: snapshot.change24hPct,
|
change24hPct: snapshot.change24hPct,
|
||||||
marketCapUsd: snapshot.marketCapUsd,
|
marketCapUsd: snapshot.marketCapUsd,
|
||||||
volume24hUsd: snapshot.volume24hUsd,
|
volume24hUsd: snapshot.volume24hUsd,
|
||||||
|
priceHistory24h: history,
|
||||||
updatedAt: snapshot.updatedAt,
|
updatedAt: snapshot.updatedAt,
|
||||||
source: "CoinGecko",
|
source: "CoinGecko",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user