Initial commit
This commit is contained in:
66
lib/api/crypto.ts
Normal file
66
lib/api/crypto.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { CRYPTO_ASSETS } from "@/lib/assets";
|
||||
|
||||
export interface CryptoRateResult {
|
||||
usdPrices: Record<string, number>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
|
||||
|
||||
export async function fetchCryptoData(): Promise<CryptoRateResult> {
|
||||
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
|
||||
(id): id is string => Boolean(id)
|
||||
);
|
||||
|
||||
const url = `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent(
|
||||
ids.join(",")
|
||||
)}&vs_currencies=usd&include_last_updated_at=true`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 60 },
|
||||
headers: {
|
||||
accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load crypto rates (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Record<
|
||||
string,
|
||||
{
|
||||
usd?: number;
|
||||
last_updated_at?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
const usdPrices: Record<string, number> = {};
|
||||
let mostRecentUpdate = 0;
|
||||
|
||||
for (const asset of CRYPTO_ASSETS) {
|
||||
if (!asset.providerId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = payload[asset.providerId];
|
||||
|
||||
if (!entry?.usd || entry.usd <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usdPrices[asset.code] = entry.usd;
|
||||
|
||||
if (entry.last_updated_at && entry.last_updated_at > mostRecentUpdate) {
|
||||
mostRecentUpdate = entry.last_updated_at;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usdPrices,
|
||||
updatedAt:
|
||||
mostRecentUpdate > 0
|
||||
? new Date(mostRecentUpdate * 1000).toISOString()
|
||||
: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
43
lib/api/fiat.ts
Normal file
43
lib/api/fiat.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface FiatRateResult {
|
||||
currencyNames: Record<string, string>;
|
||||
usdRates: Record<string, number>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const FRANKFURTER_BASE_URL = "https://api.frankfurter.app";
|
||||
|
||||
export async function fetchFiatData(): Promise<FiatRateResult> {
|
||||
const [currenciesRes, latestRes] = await Promise.all([
|
||||
fetch(`${FRANKFURTER_BASE_URL}/currencies`, {
|
||||
next: { revalidate: 60 }
|
||||
}),
|
||||
fetch(`${FRANKFURTER_BASE_URL}/latest?from=USD`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!currenciesRes.ok) {
|
||||
throw new Error(`Unable to load fiat currency names (${currenciesRes.status})`);
|
||||
}
|
||||
|
||||
if (!latestRes.ok) {
|
||||
throw new Error(`Unable to load fiat rates (${latestRes.status})`);
|
||||
}
|
||||
|
||||
const currencyNames = (await currenciesRes.json()) as Record<string, string>;
|
||||
const latest = (await latestRes.json()) as {
|
||||
date: string;
|
||||
rates: Record<string, number>;
|
||||
};
|
||||
|
||||
const usdRates: Record<string, number> = {
|
||||
USD: 1,
|
||||
...latest.rates
|
||||
};
|
||||
|
||||
return {
|
||||
currencyNames,
|
||||
usdRates,
|
||||
updatedAt: new Date(`${latest.date}T00:00:00Z`).toISOString()
|
||||
};
|
||||
}
|
||||
58
lib/api/normalize.ts
Normal file
58
lib/api/normalize.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { buildAllAssets } from "@/lib/assets";
|
||||
import { fetchCryptoData } from "@/lib/api/crypto";
|
||||
import { fetchFiatData } from "@/lib/api/fiat";
|
||||
import { RateAsset, RatesResponse } from "@/lib/rates";
|
||||
|
||||
function normalizeFiatUsdPrice(usdRate: number): number {
|
||||
return 1 / usdRate;
|
||||
}
|
||||
|
||||
export async function fetchUnifiedRates(): Promise<RatesResponse> {
|
||||
const [fiat, crypto] = await Promise.all([fetchFiatData(), fetchCryptoData()]);
|
||||
const assetDefinitions = buildAllAssets(fiat.currencyNames);
|
||||
|
||||
const assets: RateAsset[] = [];
|
||||
|
||||
for (const asset of assetDefinitions) {
|
||||
if (asset.type === "fiat") {
|
||||
const usdRate = fiat.usdRates[asset.code];
|
||||
|
||||
if (!usdRate || usdRate <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assets.push({
|
||||
...asset,
|
||||
usdPrice: normalizeFiatUsdPrice(usdRate),
|
||||
updatedAt: fiat.updatedAt
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const usdPrice = crypto.usdPrices[asset.code];
|
||||
|
||||
if (!usdPrice || usdPrice <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assets.push({
|
||||
...asset,
|
||||
usdPrice,
|
||||
updatedAt: crypto.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
const fiatUpdated = new Date(fiat.updatedAt).getTime();
|
||||
const cryptoUpdated = new Date(crypto.updatedAt).getTime();
|
||||
|
||||
return {
|
||||
assets,
|
||||
quoteCurrency: "USD",
|
||||
updatedAt: new Date(Math.max(fiatUpdated, cryptoUpdated)).toISOString(),
|
||||
sources: {
|
||||
fiat: "Frankfurter",
|
||||
crypto: "CoinGecko"
|
||||
}
|
||||
};
|
||||
}
|
||||
133
lib/assets.ts
Normal file
133
lib/assets.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export type AssetType = "fiat" | "crypto";
|
||||
|
||||
export interface AssetDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
type: AssetType;
|
||||
symbol?: string;
|
||||
providerId?: string;
|
||||
popular?: boolean;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
export const POPULAR_CODES = [
|
||||
"USD",
|
||||
"EUR",
|
||||
"GBP",
|
||||
"PLN",
|
||||
"CHF",
|
||||
"JPY",
|
||||
"BTC",
|
||||
"ETH",
|
||||
"LTC",
|
||||
"XMR",
|
||||
"SOL",
|
||||
"USDT"
|
||||
] as const;
|
||||
|
||||
export const POPULAR_CODE_SET = new Set<string>(POPULAR_CODES);
|
||||
|
||||
export const FALLBACK_FIAT_CURRENCIES: Record<string, { name: string; symbol?: string }> = {
|
||||
USD: { name: "US Dollar", symbol: "$" },
|
||||
EUR: { name: "Euro", symbol: "EUR" },
|
||||
GBP: { name: "British Pound", symbol: "GBP" },
|
||||
PLN: { name: "Polish Zloty", symbol: "PLN" },
|
||||
CHF: { name: "Swiss Franc", symbol: "CHF" },
|
||||
JPY: { name: "Japanese Yen", symbol: "JPY" },
|
||||
CAD: { name: "Canadian Dollar", symbol: "CAD" },
|
||||
AUD: { name: "Australian Dollar", symbol: "AUD" },
|
||||
NZD: { name: "New Zealand Dollar", symbol: "NZD" },
|
||||
SEK: { name: "Swedish Krona", symbol: "SEK" },
|
||||
NOK: { name: "Norwegian Krone", symbol: "NOK" },
|
||||
DKK: { name: "Danish Krone", symbol: "DKK" },
|
||||
CZK: { name: "Czech Koruna", symbol: "CZK" },
|
||||
HUF: { name: "Hungarian Forint", symbol: "HUF" },
|
||||
RON: { name: "Romanian Leu", symbol: "RON" },
|
||||
BGN: { name: "Bulgarian Lev", symbol: "BGN" },
|
||||
CNY: { name: "Chinese Yuan", symbol: "CNY" },
|
||||
HKD: { name: "Hong Kong Dollar", symbol: "HKD" },
|
||||
SGD: { name: "Singapore Dollar", symbol: "SGD" },
|
||||
INR: { name: "Indian Rupee", symbol: "INR" },
|
||||
KRW: { name: "South Korean Won", symbol: "KRW" },
|
||||
TRY: { name: "Turkish Lira", symbol: "TRY" },
|
||||
BRL: { name: "Brazilian Real", symbol: "BRL" },
|
||||
MXN: { name: "Mexican Peso", symbol: "MXN" },
|
||||
ZAR: { name: "South African Rand", symbol: "ZAR" },
|
||||
AED: { name: "UAE Dirham", symbol: "AED" },
|
||||
SAR: { name: "Saudi Riyal", symbol: "SAR" },
|
||||
ILS: { name: "Israeli New Shekel", symbol: "ILS" },
|
||||
THB: { name: "Thai Baht", symbol: "THB" },
|
||||
MYR: { name: "Malaysian Ringgit", symbol: "MYR" },
|
||||
IDR: { name: "Indonesian Rupiah", symbol: "IDR" }
|
||||
};
|
||||
|
||||
export const CRYPTO_ASSETS: AssetDefinition[] = [
|
||||
{ code: "BTC", name: "Bitcoin", type: "crypto", providerId: "bitcoin", symbol: "BTC", popular: true, decimals: 8 },
|
||||
{ code: "ETH", name: "Ethereum", type: "crypto", providerId: "ethereum", popular: true, decimals: 8 },
|
||||
{ code: "LTC", name: "Litecoin", type: "crypto", providerId: "litecoin", popular: true, decimals: 8 },
|
||||
{ code: "XMR", name: "Monero", type: "crypto", providerId: "monero", popular: true, decimals: 8 },
|
||||
{ code: "SOL", name: "Solana", type: "crypto", providerId: "solana", popular: true, decimals: 8 },
|
||||
{ code: "USDT", name: "Tether", type: "crypto", providerId: "tether", popular: true, decimals: 8 },
|
||||
{ code: "BNB", name: "BNB", type: "crypto", providerId: "binancecoin", decimals: 8 },
|
||||
{ code: "XRP", name: "XRP", type: "crypto", providerId: "ripple", decimals: 8 },
|
||||
{ code: "USDC", name: "USD Coin", type: "crypto", providerId: "usd-coin", decimals: 8 },
|
||||
{ code: "ADA", name: "Cardano", type: "crypto", providerId: "cardano", decimals: 8 },
|
||||
{ code: "DOGE", name: "Dogecoin", type: "crypto", providerId: "dogecoin", decimals: 8 },
|
||||
{ code: "TRX", name: "TRON", type: "crypto", providerId: "tron", decimals: 8 },
|
||||
{ code: "DOT", name: "Polkadot", type: "crypto", providerId: "polkadot", decimals: 8 },
|
||||
{ code: "AVAX", name: "Avalanche", type: "crypto", providerId: "avalanche-2", decimals: 8 },
|
||||
{ code: "LINK", name: "Chainlink", type: "crypto", providerId: "chainlink", decimals: 8 },
|
||||
{ code: "TON", name: "Toncoin", type: "crypto", providerId: "the-open-network", decimals: 8 },
|
||||
{ code: "NEAR", name: "NEAR Protocol", type: "crypto", providerId: "near", decimals: 8 },
|
||||
{ code: "ATOM", name: "Cosmos", type: "crypto", providerId: "cosmos", decimals: 8 },
|
||||
{ code: "BCH", name: "Bitcoin Cash", type: "crypto", providerId: "bitcoin-cash", decimals: 8 },
|
||||
{ code: "ALGO", name: "Algorand", type: "crypto", providerId: "algorand", decimals: 8 },
|
||||
{ code: "MATIC", name: "Polygon", type: "crypto", providerId: "matic-network", decimals: 8 },
|
||||
{ code: "ETC", name: "Ethereum Classic", type: "crypto", providerId: "ethereum-classic", decimals: 8 }
|
||||
];
|
||||
|
||||
export function buildFiatAssets(
|
||||
fiatCurrencies?: Record<string, string>
|
||||
): AssetDefinition[] {
|
||||
const source = fiatCurrencies && Object.keys(fiatCurrencies).length > 0
|
||||
? fiatCurrencies
|
||||
: Object.fromEntries(
|
||||
Object.entries(FALLBACK_FIAT_CURRENCIES).map(([code, value]) => [code, value.name])
|
||||
);
|
||||
|
||||
return Object.entries(source)
|
||||
.map(([code, name]) => {
|
||||
const fallback = FALLBACK_FIAT_CURRENCIES[code];
|
||||
|
||||
return {
|
||||
code,
|
||||
name: fallback?.name ?? name,
|
||||
type: "fiat" as const,
|
||||
symbol: fallback?.symbol,
|
||||
popular: POPULAR_CODE_SET.has(code)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
export function buildAllAssets(fiatCurrencies?: Record<string, string>): AssetDefinition[] {
|
||||
return [...buildFiatAssets(fiatCurrencies), ...CRYPTO_ASSETS].sort((a, b) => {
|
||||
if (a.popular && !b.popular) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!a.popular && b.popular) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "fiat" ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.code.localeCompare(b.code);
|
||||
});
|
||||
}
|
||||
|
||||
export function isPopularCode(code: string): boolean {
|
||||
return POPULAR_CODE_SET.has(code);
|
||||
}
|
||||
50
lib/fiat-flag-codes.ts
Normal file
50
lib/fiat-flag-codes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const FIAT_FLAG_CODES = new Set<string>([
|
||||
"aed",
|
||||
"ars",
|
||||
"aud",
|
||||
"bdt",
|
||||
"bgn",
|
||||
"brl",
|
||||
"cad",
|
||||
"chf",
|
||||
"clp",
|
||||
"cny",
|
||||
"cop",
|
||||
"czk",
|
||||
"dkk",
|
||||
"eur",
|
||||
"gbp",
|
||||
"gel",
|
||||
"hkd",
|
||||
"hrk",
|
||||
"huf",
|
||||
"idr",
|
||||
"inr",
|
||||
"jmd",
|
||||
"jpy",
|
||||
"kes",
|
||||
"krw",
|
||||
"lkr",
|
||||
"mad",
|
||||
"mxn",
|
||||
"myr",
|
||||
"ngn",
|
||||
"nok",
|
||||
"npr",
|
||||
"nzd",
|
||||
"pen",
|
||||
"php",
|
||||
"pkr",
|
||||
"pln",
|
||||
"ron",
|
||||
"rub",
|
||||
"sar",
|
||||
"sek",
|
||||
"sgd",
|
||||
"thb",
|
||||
"try",
|
||||
"uah",
|
||||
"usd",
|
||||
"vnd",
|
||||
"zar"
|
||||
]);
|
||||
52
lib/format.ts
Normal file
52
lib/format.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { RateAsset } from "@/lib/rates";
|
||||
|
||||
function trimTrailingZeroes(value: string): string {
|
||||
return value.replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function formatAmount(value: number, asset: RateAsset): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (asset.type === "crypto") {
|
||||
if (value === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (Math.abs(value) < 0.00000001) {
|
||||
return "<0.00000001";
|
||||
}
|
||||
|
||||
const precision = asset.decimals ?? 8;
|
||||
const formatted = value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: value < 1 ? 4 : 2,
|
||||
maximumFractionDigits: precision
|
||||
});
|
||||
|
||||
return trimTrailingZeroes(formatted);
|
||||
}
|
||||
|
||||
return value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: value < 1 ? 6 : 4
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRate(from: RateAsset, to: RateAsset): string {
|
||||
const rate = from.usdPrice / to.usdPrice;
|
||||
return formatAmount(rate, to);
|
||||
}
|
||||
|
||||
export function formatInverseRate(from: RateAsset, to: RateAsset): string {
|
||||
const inverse = to.usdPrice / from.usdPrice;
|
||||
return formatAmount(inverse, from);
|
||||
}
|
||||
|
||||
export function formatTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
55
lib/rates.ts
Normal file
55
lib/rates.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { AssetDefinition } from "@/lib/assets";
|
||||
|
||||
export interface RateAsset extends AssetDefinition {
|
||||
usdPrice: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RatesResponse {
|
||||
assets: RateAsset[];
|
||||
quoteCurrency: "USD";
|
||||
updatedAt: string;
|
||||
sources: {
|
||||
fiat: string;
|
||||
crypto: string;
|
||||
};
|
||||
}
|
||||
|
||||
const rateAssetSchema = z.object({
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["fiat", "crypto"]),
|
||||
symbol: z.string().optional(),
|
||||
providerId: z.string().optional(),
|
||||
popular: z.boolean().optional(),
|
||||
decimals: z.number().optional(),
|
||||
usdPrice: z.number().positive(),
|
||||
updatedAt: z.string()
|
||||
});
|
||||
|
||||
const ratesResponseSchema = z.object({
|
||||
assets: z.array(rateAssetSchema),
|
||||
quoteCurrency: z.literal("USD"),
|
||||
updatedAt: z.string(),
|
||||
sources: z.object({
|
||||
fiat: z.string(),
|
||||
crypto: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
export function parseRatesResponse(payload: unknown): RatesResponse {
|
||||
return ratesResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export function buildRateMap(assets: RateAsset[]): Map<string, RateAsset> {
|
||||
return new Map(assets.map((asset) => [asset.code, asset]));
|
||||
}
|
||||
|
||||
export function convertAmount(
|
||||
amount: number,
|
||||
fromAsset: RateAsset,
|
||||
toAsset: RateAsset
|
||||
): number {
|
||||
return amount * (fromAsset.usdPrice / toAsset.usdPrice);
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
26
lib/validation.ts
Normal file
26
lib/validation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const amountSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Enter an amount")
|
||||
.refine((value) => !Number.isNaN(Number(value)), "Enter a valid number")
|
||||
.transform((value) => Number(value))
|
||||
.refine((value) => Number.isFinite(value), "Enter a valid number")
|
||||
.refine((value) => value > 0, "Amount must be greater than zero")
|
||||
.refine((value) => value <= 1_000_000_000_000, "Amount is too large");
|
||||
|
||||
export function validateAmount(amount: string):
|
||||
| { ok: true; value: number }
|
||||
| { ok: false; error: string } {
|
||||
const result = amountSchema.safeParse(amount);
|
||||
|
||||
if (result.success) {
|
||||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: result.error.issues[0]?.message ?? "Invalid amount"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user