feat(market): add CoinGecko market data panel with cached /api/market endpoint
This commit is contained in:
14
README.md
14
README.md
@@ -42,6 +42,7 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset
|
|||||||
.
|
.
|
||||||
├── app
|
├── app
|
||||||
│ ├── api/convert/route.ts
|
│ ├── api/convert/route.ts
|
||||||
|
│ ├── api/market/route.ts
|
||||||
│ ├── api/rates/route.ts
|
│ ├── api/rates/route.ts
|
||||||
│ ├── globals.css
|
│ ├── globals.css
|
||||||
│ ├── layout.tsx
|
│ ├── layout.tsx
|
||||||
@@ -63,6 +64,7 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset
|
|||||||
│ ├── separator.tsx
|
│ ├── separator.tsx
|
||||||
│ └── skeleton.tsx
|
│ └── skeleton.tsx
|
||||||
├── hooks
|
├── hooks
|
||||||
|
│ ├── use-crypto-market.ts
|
||||||
│ ├── use-debounced-value.ts
|
│ ├── use-debounced-value.ts
|
||||||
│ └── use-market-rates.ts
|
│ └── use-market-rates.ts
|
||||||
├── lib
|
├── lib
|
||||||
@@ -70,9 +72,15 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset
|
|||||||
│ │ ├── crypto.ts
|
│ │ ├── crypto.ts
|
||||||
│ │ ├── fiat.ts
|
│ │ ├── fiat.ts
|
||||||
│ │ └── normalize.ts
|
│ │ └── normalize.ts
|
||||||
|
│ ├── api/url.ts
|
||||||
│ ├── assets.ts
|
│ ├── assets.ts
|
||||||
|
│ ├── currency-display.ts
|
||||||
│ ├── format.ts
|
│ ├── format.ts
|
||||||
|
│ ├── market.ts
|
||||||
│ ├── rates.ts
|
│ ├── rates.ts
|
||||||
|
│ ├── server
|
||||||
|
│ │ ├── market-cache.ts
|
||||||
|
│ │ └── rates-cache.ts
|
||||||
│ ├── utils.ts
|
│ ├── utils.ts
|
||||||
│ └── validation.ts
|
│ └── validation.ts
|
||||||
├── .env.example
|
├── .env.example
|
||||||
@@ -153,13 +161,17 @@ If empty, the app uses the local default (`/api/rates`).
|
|||||||
- `GET /api/convert?from=USD&to=BTC&amount=100`
|
- `GET /api/convert?from=USD&to=BTC&amount=100`
|
||||||
- Converts between any supported fiat/crypto pair using current normalized rates.
|
- Converts between any supported fiat/crypto pair using current normalized rates.
|
||||||
- Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`.
|
- 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`.
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
- `app/api/rates/route.ts` is the single internal market endpoint for the frontend.
|
- `app/api/rates/route.ts` is the single internal market endpoint for the frontend.
|
||||||
- `app/api/convert/route.ts` provides direct server-side conversion for external/API consumers.
|
- `app/api/convert/route.ts` provides direct server-side conversion for external/API consumers.
|
||||||
|
- `app/api/market/route.ts` provides crypto market snapshot data (price, 24h, market cap, volume).
|
||||||
- Provider modules are isolated in `lib/api/` so they can be swapped independently.
|
- Provider modules are isolated in `lib/api/` so they can be swapped independently.
|
||||||
- Shared in-memory caching is centralized in `lib/server/rates-cache.ts`.
|
- Shared in-memory caching is centralized in `lib/server/`.
|
||||||
- `lib/api/normalize.ts` unifies fiat and crypto responses into one shape used by UI.
|
- `lib/api/normalize.ts` unifies fiat and crypto responses into one shape used by UI.
|
||||||
- Conversion formula is provider-agnostic:
|
- Conversion formula is provider-agnostic:
|
||||||
- `result = amount * (from.usdPrice / to.usdPrice)`
|
- `result = amount * (from.usdPrice / to.usdPrice)`
|
||||||
|
|||||||
87
app/api/market/route.ts
Normal file
87
app/api/market/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getRatesWithCache } from "@/lib/server/rates-cache";
|
||||||
|
import {
|
||||||
|
getCryptoMarketWithCache,
|
||||||
|
getLastCachedCryptoMarket,
|
||||||
|
MARKET_CACHE_CONTROL_VALUE,
|
||||||
|
} from "@/lib/server/market-cache";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
code: z.string().trim().toUpperCase().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryResult = querySchema.safeParse({
|
||||||
|
code: searchParams.get("code") ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!queryResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
queryResult.error.issues[0]?.message ?? "Invalid query parameters",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = queryResult.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rates = await getRatesWithCache();
|
||||||
|
const asset = rates.assets.find((item) => item.code === code);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Unsupported currency or asset code" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.type !== "crypto" || !asset.providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Market data is available only for crypto assets" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const market = await getCryptoMarketWithCache({
|
||||||
|
code: asset.code,
|
||||||
|
name: asset.name,
|
||||||
|
providerId: asset.providerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(market, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": MARKET_CACHE_CONTROL_VALUE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = getLastCachedCryptoMarket(code);
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return NextResponse.json(fallback, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": MARKET_CACHE_CONTROL_VALUE,
|
||||||
|
"X-Cache-Fallback": "stale-on-error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unexpected error while loading market data";
|
||||||
|
|
||||||
|
return NextResponse.json({ message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
BarChart3,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useCryptoMarket } from "@/hooks/use-crypto-market";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { getDisplaySymbol } from "@/lib/currency-display";
|
import { getDisplaySymbol } from "@/lib/currency-display";
|
||||||
import { useMarketRates } from "@/hooks/use-market-rates";
|
import { useMarketRates } from "@/hooks/use-market-rates";
|
||||||
@@ -31,7 +33,10 @@ import {
|
|||||||
formatAmount,
|
formatAmount,
|
||||||
formatInverseRate,
|
formatInverseRate,
|
||||||
formatRate,
|
formatRate,
|
||||||
|
formatSignedPercent,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
formatUsdCompact,
|
||||||
|
formatUsdPrice,
|
||||||
} from "@/lib/format";
|
} from "@/lib/format";
|
||||||
import { buildRateMap, convertAmount } from "@/lib/rates";
|
import { buildRateMap, convertAmount } from "@/lib/rates";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -203,6 +208,25 @@ export function ConverterCard({
|
|||||||
setToCode(fromCode);
|
setToCode(fromCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const marketAsset = useMemo(() => {
|
||||||
|
if (toAsset?.type === "crypto") {
|
||||||
|
return toAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromAsset?.type === "crypto") {
|
||||||
|
return fromAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [fromAsset, toAsset]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: marketData,
|
||||||
|
error: marketError,
|
||||||
|
isLoading: isMarketLoading,
|
||||||
|
refresh: refreshMarket,
|
||||||
|
} = useCryptoMarket(marketAsset?.code ?? null);
|
||||||
|
|
||||||
const handleCopyConvertedValue = async () => {
|
const handleCopyConvertedValue = async () => {
|
||||||
if (convertedValue === null || !toAsset) {
|
if (convertedValue === null || !toAsset) {
|
||||||
return;
|
return;
|
||||||
@@ -478,12 +502,99 @@ export function ConverterCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{marketAsset ? (
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/40 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-xs uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
<BarChart3 className="h-3.5 w-3.5" />
|
||||||
|
Market data
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-xs text-foreground">
|
||||||
|
<CurrencyIcon
|
||||||
|
code={marketAsset.code}
|
||||||
|
type={marketAsset.type}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{marketAsset.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{marketError && !marketData ? (
|
||||||
|
<p className="mt-3 text-xs text-red-300/90">
|
||||||
|
Unable to load market data right now.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Price
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-medium text-foreground">
|
||||||
|
{marketData ? formatUsdPrice(marketData.priceUsd) : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
24h
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-base font-medium text-foreground",
|
||||||
|
marketData && marketData.change24hPct !== null
|
||||||
|
? marketData.change24hPct > 0
|
||||||
|
? "text-emerald-300"
|
||||||
|
: marketData.change24hPct < 0
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-foreground"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{marketData
|
||||||
|
? formatSignedPercent(marketData.change24hPct)
|
||||||
|
: "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Market cap
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-medium text-foreground">
|
||||||
|
{marketData ? formatUsdCompact(marketData.marketCapUsd) : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Volume (24h)
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-medium text-foreground">
|
||||||
|
{marketData ? formatUsdCompact(marketData.volume24hUsd) : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex min-h-4 items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{marketData
|
||||||
|
? `Updated ${formatTimestamp(marketData.updatedAt)}`
|
||||||
|
: isMarketLoading
|
||||||
|
? "Updating market data..."
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
{marketData ? <span>Source: {marketData.source}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void refresh()}
|
onClick={() => {
|
||||||
|
void refresh();
|
||||||
|
void refreshMarket();
|
||||||
|
}}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
94
hooks/use-crypto-market.ts
Normal file
94
hooks/use-crypto-market.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { buildApiUrl } from "@/lib/api/url";
|
||||||
|
import {
|
||||||
|
parseCryptoMarketResponse,
|
||||||
|
type CryptoMarketResponse,
|
||||||
|
} from "@/lib/market";
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
export function useCryptoMarket(assetCode: string | null) {
|
||||||
|
const [data, setData] = useState<CryptoMarketResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(null);
|
||||||
|
setError(null);
|
||||||
|
}, [assetCode]);
|
||||||
|
|
||||||
|
const fetchMarket = useCallback(async () => {
|
||||||
|
if (!assetCode) {
|
||||||
|
setData(null);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
buildApiUrl(`/api/market?code=${encodeURIComponent(assetCode)}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| { message?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
throw new Error(payload?.message ?? "Unable to fetch market data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const parsed = parseCryptoMarketResponse(payload);
|
||||||
|
setData(parsed);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [assetCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchMarket();
|
||||||
|
|
||||||
|
if (!assetCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void fetchMarket();
|
||||||
|
}
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
void fetchMarket();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [assetCode, fetchMarket]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
refresh: fetchMarket,
|
||||||
|
}),
|
||||||
|
[data, error, isLoading, fetchMarket],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,20 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { buildApiUrl } from "@/lib/api/url";
|
||||||
import { parseRatesResponse, RatesResponse } from "@/lib/rates";
|
import { parseRatesResponse, RatesResponse } from "@/lib/rates";
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 60_000;
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
function buildApiUrl(path: string): string {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
|
|
||||||
|
|
||||||
if (!base) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base.replace(/\/$/, "")}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarketRates() {
|
export function useMarketRates() {
|
||||||
const [data, setData] = useState<RatesResponse | null>(null);
|
const [data, setData] = useState<RatesResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export interface CryptoRateResult {
|
|||||||
updatedAt: string;
|
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";
|
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
||||||
|
|
||||||
function buildCoinGeckoHeaders(): HeadersInit {
|
function buildCoinGeckoHeaders(): HeadersInit {
|
||||||
@@ -39,6 +47,50 @@ function buildCoinGeckoHeaders(): HeadersInit {
|
|||||||
return headers;
|
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> {
|
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)
|
||||||
|
|||||||
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"
|
timeStyle: "short"
|
||||||
}).format(date);
|
}).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