feat(market): add CoinGecko market data panel with cached /api/market endpoint
This commit is contained in:
@@ -5,6 +5,14 @@ export interface CryptoRateResult {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CryptoMarketSnapshot {
|
||||
priceUsd: number;
|
||||
change24hPct: number | null;
|
||||
marketCapUsd: number | null;
|
||||
volume24hUsd: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
||||
|
||||
function buildCoinGeckoHeaders(): HeadersInit {
|
||||
@@ -39,6 +47,50 @@ function buildCoinGeckoHeaders(): HeadersInit {
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function fetchCryptoMarketSnapshot(
|
||||
providerId: string,
|
||||
): Promise<CryptoMarketSnapshot> {
|
||||
const url = `${COINGECKO_BASE_URL}/coins/markets?vs_currency=usd&ids=${encodeURIComponent(
|
||||
providerId,
|
||||
)}&price_change_percentage=24h`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 60 },
|
||||
headers: buildCoinGeckoHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load crypto market data (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Array<{
|
||||
current_price?: number;
|
||||
market_cap?: number;
|
||||
total_volume?: number;
|
||||
price_change_percentage_24h?: number;
|
||||
last_updated?: string;
|
||||
}>;
|
||||
|
||||
const market = payload[0];
|
||||
|
||||
if (!market?.current_price || market.current_price <= 0) {
|
||||
throw new Error("Crypto market data is unavailable");
|
||||
}
|
||||
|
||||
return {
|
||||
priceUsd: market.current_price,
|
||||
change24hPct:
|
||||
typeof market.price_change_percentage_24h === "number"
|
||||
? market.price_change_percentage_24h
|
||||
: null,
|
||||
marketCapUsd:
|
||||
typeof market.market_cap === "number" ? market.market_cap : null,
|
||||
volume24hUsd:
|
||||
typeof market.total_volume === "number" ? market.total_volume : null,
|
||||
updatedAt: market.last_updated ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCryptoData(): Promise<CryptoRateResult> {
|
||||
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
|
||||
(id): id is string => Boolean(id)
|
||||
|
||||
9
lib/api/url.ts
Normal file
9
lib/api/url.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function buildApiUrl(path: string): string {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
|
||||
|
||||
if (!base) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${base.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
@@ -45,3 +45,47 @@ export function formatTimestamp(value: string): string {
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatUsdPrice(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 1) {
|
||||
return value.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
return value.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 8,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUsdCompact(value: number | null): string {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return value.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSignedPercent(value: number | null): string {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
29
lib/market.ts
Normal file
29
lib/market.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface CryptoMarketResponse {
|
||||
code: string;
|
||||
name: string;
|
||||
priceUsd: number;
|
||||
change24hPct: number | null;
|
||||
marketCapUsd: number | null;
|
||||
volume24hUsd: number | null;
|
||||
updatedAt: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const marketResponseSchema = z.object({
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
priceUsd: z.number().positive(),
|
||||
change24hPct: z.number().nullable(),
|
||||
marketCapUsd: z.number().nullable(),
|
||||
volume24hUsd: z.number().nullable(),
|
||||
updatedAt: z.string(),
|
||||
source: z.string(),
|
||||
});
|
||||
|
||||
export function parseCryptoMarketResponse(
|
||||
payload: unknown,
|
||||
): CryptoMarketResponse {
|
||||
return marketResponseSchema.parse(payload);
|
||||
}
|
||||
80
lib/server/market-cache.ts
Normal file
80
lib/server/market-cache.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { fetchCryptoMarketSnapshot } from "@/lib/api/crypto";
|
||||
|
||||
export interface CachedCryptoMarketSnapshot {
|
||||
code: string;
|
||||
name: string;
|
||||
priceUsd: number;
|
||||
change24hPct: number | null;
|
||||
marketCapUsd: number | null;
|
||||
volume24hUsd: number | null;
|
||||
updatedAt: string;
|
||||
source: "CoinGecko";
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: CachedCryptoMarketSnapshot;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
export const MARKET_CACHE_CONTROL_VALUE =
|
||||
"s-maxage=60, stale-while-revalidate=600";
|
||||
|
||||
const marketCache = new Map<string, CacheEntry>();
|
||||
const inFlightRequests = new Map<string, Promise<CachedCryptoMarketSnapshot>>();
|
||||
|
||||
export async function getCryptoMarketWithCache(params: {
|
||||
code: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}): Promise<CachedCryptoMarketSnapshot> {
|
||||
const cacheKey = params.code;
|
||||
const cached = marketCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const pending = inFlightRequests.get(cacheKey);
|
||||
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const snapshot = await fetchCryptoMarketSnapshot(params.providerId);
|
||||
|
||||
const normalized: CachedCryptoMarketSnapshot = {
|
||||
code: params.code,
|
||||
name: params.name,
|
||||
priceUsd: snapshot.priceUsd,
|
||||
change24hPct: snapshot.change24hPct,
|
||||
marketCapUsd: snapshot.marketCapUsd,
|
||||
volume24hUsd: snapshot.volume24hUsd,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
source: "CoinGecko",
|
||||
};
|
||||
|
||||
marketCache.set(cacheKey, {
|
||||
data: normalized,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return normalized;
|
||||
})();
|
||||
|
||||
inFlightRequests.set(cacheKey, requestPromise);
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
inFlightRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastCachedCryptoMarket(
|
||||
code: string,
|
||||
): CachedCryptoMarketSnapshot | null {
|
||||
return marketCache.get(code)?.data ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user