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}
+