diff --git a/README.md b/README.md index 373f11a..49376cc 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset . ├── app │ ├── api/convert/route.ts +│ ├── api/market/route.ts │ ├── api/rates/route.ts │ ├── globals.css │ ├── layout.tsx @@ -63,6 +64,7 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset │ ├── separator.tsx │ └── skeleton.tsx ├── hooks +│ ├── use-crypto-market.ts │ ├── use-debounced-value.ts │ └── use-market-rates.ts ├── lib @@ -70,9 +72,15 @@ The app normalizes both feeds into a shared internal model (`usdPrice` per asset │ │ ├── crypto.ts │ │ ├── fiat.ts │ │ └── normalize.ts +│ ├── api/url.ts │ ├── assets.ts +│ ├── currency-display.ts │ ├── format.ts +│ ├── market.ts │ ├── rates.ts +│ ├── server +│ │ ├── market-cache.ts +│ │ └── rates-cache.ts │ ├── utils.ts │ └── validation.ts ├── .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` - Converts between any supported fiat/crypto pair using current normalized 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`. ## Architecture Notes - `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/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. -- 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. - Conversion formula is provider-agnostic: - `result = amount * (from.usdPrice / to.usdPrice)` diff --git a/app/api/market/route.ts b/app/api/market/route.ts new file mode 100644 index 0000000..e70ef22 --- /dev/null +++ b/app/api/market/route.ts @@ -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 }); + } +} diff --git a/components/converter/converter-card.tsx b/components/converter/converter-card.tsx index 5ddefa0..dcb2b16 100644 --- a/components/converter/converter-card.tsx +++ b/components/converter/converter-card.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, ArrowUpDown, + BarChart3, Check, Copy, Loader2, @@ -24,6 +25,7 @@ import { import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { useCryptoMarket } from "@/hooks/use-crypto-market"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { getDisplaySymbol } from "@/lib/currency-display"; import { useMarketRates } from "@/hooks/use-market-rates"; @@ -31,7 +33,10 @@ import { formatAmount, formatInverseRate, formatRate, + formatSignedPercent, formatTimestamp, + formatUsdCompact, + formatUsdPrice, } from "@/lib/format"; import { buildRateMap, convertAmount } from "@/lib/rates"; import { cn } from "@/lib/utils"; @@ -203,6 +208,25 @@ export function ConverterCard({ 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 () => { if (convertedValue === null || !toAsset) { return; @@ -478,12 +502,99 @@ export function ConverterCard({ + {marketAsset ? ( +
+
+

+ + Market data +

+ + + {marketAsset.code} + +
+ + {marketError && !marketData ? ( +

+ Unable to load market data right now. +

+ ) : null} + +
+
+

+ Price +

+

+ {marketData ? formatUsdPrice(marketData.priceUsd) : "-"} +

+
+
+

+ 24h +

+

0 + ? "text-emerald-300" + : marketData.change24hPct < 0 + ? "text-red-300" + : "text-foreground" + : "text-foreground", + )} + > + {marketData + ? formatSignedPercent(marketData.change24hPct) + : "-"} +

+
+
+

+ Market cap +

+

+ {marketData ? formatUsdCompact(marketData.marketCapUsd) : "-"} +

+
+
+

+ Volume (24h) +

+

+ {marketData ? formatUsdCompact(marketData.volume24hUsd) : "-"} +

+
+
+ +
+ + {marketData + ? `Updated ${formatTimestamp(marketData.updatedAt)}` + : isMarketLoading + ? "Updating market data..." + : ""} + + {marketData ? Source: {marketData.source} : null} +
+
+ ) : null} +