From 820902b2afd77a7d8a430e3e7a1595180d9affef Mon Sep 17 00:00:00 2001 From: Mohd Zishan <72738005+Zishan-7@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:41:36 +0530 Subject: [PATCH 01/13] HOTFIX - IBAN country detection and incorrect bank acc details (#1094) * Fix: Iban country detection and incorrect bank acc details * Fix: update IBAN country validation to use correct locale string comparison * add validations for US and mexican bank accounts * fix typo * fix claim flow and create a reusable function for getting 3 letter code * fix country code mismatch * fix: show error below input field * remove unnecessary checks * remove unnecessary CLABE check --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 12 +- .../AddWithdraw/DynamicBankAccountForm.tsx | 35 +++-- .../Claim/Link/views/BankFlowManager.view.tsx | 4 +- src/utils/withdraw.utils.ts | 147 ++++++++++++++++++ 4 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 src/utils/withdraw.utils.ts diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 24ce2f325..0d0ad6365 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -1,6 +1,11 @@ 'use client' -import { COUNTRY_SPECIFIC_METHODS, countryData, SpecificPaymentMethod } from '@/components/AddMoney/consts' +import { + COUNTRY_SPECIFIC_METHODS, + countryCodeMap, + countryData, + SpecificPaymentMethod, +} from '@/components/AddMoney/consts' import StatusBadge from '@/components/Global/Badges/StatusBadge' import { IconName } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' @@ -11,7 +16,7 @@ import Image, { StaticImageData } from 'next/image' import { useParams, useRouter } from 'next/navigation' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { InitiateKYCModal } from '@/components/Kyc' import { DynamicBankAccountForm, IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount, updateUserById } from '@/app/actions/users' @@ -23,6 +28,7 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useOnrampFlow } from '@/context/OnrampFlowContext' import { Account } from '@/interfaces' import PeanutLoading from '../Global/PeanutLoading' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -212,7 +218,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 5cb050530..038ee3bed 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -13,6 +13,7 @@ import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' const isIBANCountry = (country: string) => { return countryCodeMap[country.toUpperCase()] !== undefined @@ -41,19 +42,22 @@ interface DynamicBankAccountFormProps { initialData?: Partial flow?: 'claim' | 'withdraw' actionDetailsProps?: Partial + countryName?: string } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( - ({ country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { + ({ country, countryName, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { const { user } = useAuth() const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) const [showBicField, setShowBicField] = useState(false) - const { country: countryName } = useParams() + const { country: countryNameParams } = useParams() const { amountToWithdraw } = useWithdrawFlow() const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') const lastName = lastNameParts.join(' ') + let selectedCountry = (countryName ?? (countryNameParams as string)).toLowerCase() + const { control, handleSubmit, @@ -85,9 +89,9 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D setIsSubmitting(true) setSubmissionError(null) try { - const isIban = isIBANCountry(country) - const isUs = country.toUpperCase() === 'US' + const isUs = country.toUpperCase() === 'USA' const isMx = country.toUpperCase() === 'MX' + const isIban = isUs || isMx ? false : isIBANCountry(country) let accountType: BridgeAccountType if (isIban) accountType = BridgeAccountType.IBAN @@ -121,7 +125,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D accountType, accountNumber: accountNumber.replace(/\s/g, ''), countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: countryName as string, + countryName: selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -159,9 +163,9 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D } } - const isIban = isIBANCountry(country) - const isUs = country.toUpperCase() === 'US' const isMx = country.toUpperCase() === 'MX' + const isUs = country.toUpperCase() === 'USA' + const isIban = isUs || isMx ? false : isIBANCountry(country) const renderInput = ( name: keyof IBankAccountDetails, @@ -246,14 +250,25 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D required: 'CLABE is required', minLength: { value: 18, message: 'CLABE must be 18 digits' }, maxLength: { value: 18, message: 'CLABE must be 18 digits' }, + validate: async (value: string) => + validateMXCLabeAccount(value).isValid || 'Invalid CLABE', }) : isIban ? renderInput( 'accountNumber', 'IBAN', { - required: 'Account number is required', - validate: async (val: string) => (await validateIban(val)) || 'Invalid IBAN', + required: 'IBAN is required', + validate: async (val: string) => { + const isValidIban = await validateIban(val) + if (!isValidIban) return 'Invalid IBAN' + + if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { + return 'IBAN does not match the selected country' + } + + return true + }, }, 'text', undefined, @@ -267,7 +282,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D { required: 'Account number is required', validate: async (value: string) => - (await validateBankAccount(value)) || 'Invalid account number', + validateUSBankAccount(value).isValid || 'Invalid account number', }, 'text' )} diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 7d98e6f3b..60afba58b 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -20,6 +20,7 @@ import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts. import peanut from '@squirrel-labs/peanut-sdk' import { getUserById } from '@/app/actions/users' import NavHeader from '@/components/Global/NavHeader' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' export const BankFlowManager = (props: IClaimScreenProps) => { const { onCustom, claimLinkData, setTransactionHash } = props @@ -214,7 +215,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
{ + // Remove spaces and convert to uppercase + const cleanIban = iban.replace(/\s/g, '').toUpperCase() + + // Extract the first 2 characters as country code + const countryCode = cleanIban.substring(0, 2) + + // Try to find country by 2-letter code directly in countryData + let country = countryData.find((c) => c.type === 'country' && c.id === countryCode) + + // If not found, get the 3-letter code and try that + if (!country) { + const threeLetterCode = getCountryCodeForWithdraw(countryCode) + if (threeLetterCode !== countryCode) { + country = countryData.find((c) => c.type === 'country' && c.id === threeLetterCode) + } + } + + return country ? country.title : null +} + +/** + * Validates a US bank account number with comprehensive checks + * @param accountNumber - The bank account number to validate + * @returns Object with isValid boolean and error message if invalid + */ +export const validateUSBankAccount = (accountNumber: string) => { + // Remove spaces and hyphens for validation + const cleanAccountNumber = accountNumber.replace(/[\s-]/g, '') + + // Check if contains only digits + if (!/^\d+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'Account number must contain only digits', + } + } + + // Check minimum length (US bank accounts are typically 6-17 digits) + if (cleanAccountNumber.length < 6) { + return { + isValid: false, + error: 'Account number must be at least 6 digits', + } + } + + // Check maximum length + if (cleanAccountNumber.length > 17) { + return { + isValid: false, + error: 'Account number cannot exceed 17 digits', + } + } + + // Check for obviously invalid patterns + if (/^0+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'Account number cannot be all zeros', + } + } + + return { + isValid: true, + error: null, + } +} + +/** + * Validates a Mexican CLABE (Clave Bancaria Estandarizada) account number + * CLABE is exactly 18 digits with a specific structure and check digit validation + * @param accountNumber - The CLABE account number to validate + * @returns Object with isValid boolean and error message if invalid + */ +export const validateMXCLabeAccount = (accountNumber: string) => { + // Remove spaces and hyphens for validation + const cleanAccountNumber = accountNumber.replace(/[\s-]/g, '') + + // Check if contains only digits + if (!/^\d+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'CLABE must contain only digits', + } + } + + // CLABE must be exactly 18 digits + if (cleanAccountNumber.length !== 18) { + return { + isValid: false, + error: 'CLABE must be exactly 18 digits', + } + } + + // Check for obviously invalid patterns + if (/^0+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'CLABE cannot be all zeros', + } + } + + // Validate CLABE check digit using the official algorithm + const digits = cleanAccountNumber.split('').map(Number) + const weights = [3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7] + + let sum = 0 + for (let i = 0; i < 17; i++) { + sum += digits[i] * weights[i] + } + + const remainder = sum % 10 + const calculatedCheckDigit = remainder === 0 ? 0 : 10 - remainder + const providedCheckDigit = digits[17] + + if (calculatedCheckDigit !== providedCheckDigit) { + return { + isValid: false, + error: 'CLABE check digit is invalid', + } + } + + return { + isValid: true, + error: null, + } +} + +// Returns the 3-letter country code for the given country code +export const getCountryCodeForWithdraw = (country: string) => { + // If the input is already a 3-digit code and exists in the map, return it + if (countryCodeMap[country]) { + return country + } + + // If the input is a 2-digit code, find the corresponding 3-digit code + const threeDigitCode = Object.keys(countryCodeMap).find((key) => countryCodeMap[key] === country) + + return threeDigitCode || country +} From ae3ac97ce40e44d51727ba216cc374c13bb22dee Mon Sep 17 00:00:00 2001 From: Mohd Zishan <72738005+Zishan-7@users.noreply.github.com> Date: Fri, 15 Aug 2025 14:30:21 +0530 Subject: [PATCH 02/13] Prod LP v2.1 (#1098) * feat: lpv2.1 * fix: gigaclouds, font and exchange widget * fixes and improvements * remove duplicate export * remove unused component --- scripts/compare-rates.mjs | 196 ++++ src/app/api/exchange-rate/route.ts | 160 +++ src/app/exchange/page.tsx | 13 + src/app/page.tsx | 17 +- src/assets/icons/bbva-logo.svg | 6 + src/assets/icons/brubank-logo.svg | 28 + src/assets/icons/github-white.png | Bin 0 -> 3547 bytes src/assets/icons/index.ts | 12 +- src/assets/icons/mercado-pago-logo.svg | 15 + src/assets/icons/n26-logo.svg | 3 + src/assets/icons/pix-logo.svg | 31 + src/assets/icons/revolut-logo.svg | 3 + src/assets/icons/santander-logo.svg | 3 + src/assets/icons/stripe-logo.svg | 9 + src/assets/icons/wise-logo.svg | 3 + .../illustrations/hand-middle-finger.svg | 4 + src/assets/illustrations/index.ts | 1 + .../illustrations/landing-countries.svg | 934 ++++++++++++++++++ .../illustrations/mobile-send-in-seconds.svg | 14 +- src/assets/illustrations/no-hidden-fees.svg | 192 ++-- src/assets/illustrations/pay-zero-fees.svg | 34 +- .../iphone-ss/iphone-drop-a-link-mobile.png | Bin 0 -> 298719 bytes src/assets/iphone-ss/iphone-drop-a-link.png | Bin 0 -> 198572 bytes src/components/Global/Icons/Icon.tsx | 3 + src/components/Global/Icons/chevron-down.tsx | 10 + src/components/LandingPage/CurrencySelect.tsx | 196 ++++ src/components/LandingPage/Footer.tsx | 112 +++ src/components/LandingPage/RegulatedRails.tsx | 148 +++ .../LandingPage/businessIntegrate.tsx | 67 -- src/components/LandingPage/dropLink.tsx | 72 ++ src/components/LandingPage/hero.tsx | 12 +- src/components/LandingPage/imageAssets.tsx | 13 +- src/components/LandingPage/index.ts | 3 +- src/components/LandingPage/noFees.tsx | 286 ++++-- .../LandingPage/securityBuiltIn.tsx | 20 +- src/components/LandingPage/yourMoney.tsx | 61 +- src/constants/countryCurrencyMapping.ts | 37 + src/hooks/useExchangeRate.ts | 147 +++ src/styles/globals.css | 6 + 39 files changed, 2565 insertions(+), 306 deletions(-) create mode 100644 scripts/compare-rates.mjs create mode 100644 src/app/api/exchange-rate/route.ts create mode 100644 src/app/exchange/page.tsx create mode 100644 src/assets/icons/bbva-logo.svg create mode 100644 src/assets/icons/brubank-logo.svg create mode 100644 src/assets/icons/github-white.png create mode 100644 src/assets/icons/mercado-pago-logo.svg create mode 100644 src/assets/icons/n26-logo.svg create mode 100644 src/assets/icons/pix-logo.svg create mode 100644 src/assets/icons/revolut-logo.svg create mode 100644 src/assets/icons/santander-logo.svg create mode 100644 src/assets/icons/stripe-logo.svg create mode 100644 src/assets/icons/wise-logo.svg create mode 100644 src/assets/illustrations/hand-middle-finger.svg create mode 100644 src/assets/illustrations/landing-countries.svg create mode 100644 src/assets/iphone-ss/iphone-drop-a-link-mobile.png create mode 100644 src/assets/iphone-ss/iphone-drop-a-link.png create mode 100644 src/components/Global/Icons/chevron-down.tsx create mode 100644 src/components/LandingPage/CurrencySelect.tsx create mode 100644 src/components/LandingPage/Footer.tsx create mode 100644 src/components/LandingPage/RegulatedRails.tsx delete mode 100644 src/components/LandingPage/businessIntegrate.tsx create mode 100644 src/components/LandingPage/dropLink.tsx create mode 100644 src/constants/countryCurrencyMapping.ts create mode 100644 src/hooks/useExchangeRate.ts diff --git a/scripts/compare-rates.mjs b/scripts/compare-rates.mjs new file mode 100644 index 000000000..660568ce4 --- /dev/null +++ b/scripts/compare-rates.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +// Compare Bridge vs Frankfurter (and optionally local /api/exchange-rate) rates +// Usage examples: +// node scripts/compare-rates.mjs +// node scripts/compare-rates.mjs --pairs USD:EUR,USD:MXN --api http://localhost:3000/api/exchange-rate +// BRIDGE_API_KEY=xxx node scripts/compare-rates.mjs + +import { readFileSync } from 'fs' +import { resolve } from 'path' + +// Load .env files +function loadEnv() { + const envFiles = ['.env.local', '.env'] + for (const file of envFiles) { + try { + const envPath = resolve(file) + const envContent = readFileSync(envPath, 'utf8') + envContent.split('\n').forEach((line) => { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('=') + if (key && valueParts.length > 0) { + const value = valueParts.join('=').replace(/^["']|["']$/g, '') + if (!process.env[key]) { + process.env[key] = value + } + } + } + }) + console.log(`Loaded environment from ${file}`) + break + } catch (e) { + // File doesn't exist, continue to next + } + } +} + +loadEnv() + +const DEFAULT_PAIRS = [ + ['USD', 'EUR'], + ['USD', 'MXN'], + ['USD', 'BRL'], + ['EUR', 'USD'], + ['EUR', 'GBP'], +] + +const params = process.argv.slice(2) +const pairsArg = getArg('--pairs') +const apiArg = getArg('--api') + +const PAIRS = pairsArg ? pairsArg.split(',').map((p) => p.split(':').map((s) => s.trim().toUpperCase())) : DEFAULT_PAIRS + +const BRIDGE_API_KEY = process.env.BRIDGE_API_KEY + +function getArg(name) { + const i = params.indexOf(name) + if (i === -1) return null + return params[i + 1] || null +} + +async function fetchBridge(from, to) { + if (!BRIDGE_API_KEY) { + return { error: 'Missing BRIDGE_API_KEY' } + } + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const res = await fetch(url, { + method: 'GET', + headers: { 'Api-Key': BRIDGE_API_KEY }, + }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const { midmarket_rate, buy_rate, sell_rate } = data || {} + return { midmarket_rate, buy_rate, sell_rate } +} + +async function fetchFrankfurter(from, to) { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const rate = data?.rates?.[to] + return { rate, rate_995: typeof rate === 'number' ? rate * 0.995 : undefined } +} + +async function fetchLocalApi(from, to) { + if (!apiArg) return {} + try { + const url = `${apiArg}?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + return { rate: data?.rate } + } catch (error) { + return { error: `Connection failed: ${error.message}` } + } +} + +function fmt(n, digits = 6) { + return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : '-' +} + +function bps(a, b) { + if (typeof a !== 'number' || typeof b !== 'number' || !Number.isFinite(a) || !Number.isFinite(b) || b === 0) + return '-' + const rel = (a / b - 1) * 10000 + return `${rel.toFixed(1)} bps` +} + +async function run() { + console.log('Comparing rates...') + if (!BRIDGE_API_KEY) { + console.warn('Warning: BRIDGE_API_KEY not set. Bridge calls will be skipped or return errors.') + } + if (apiArg) { + console.log(`Also querying local API: ${apiArg}`) + } + + for (const [from, to] of PAIRS) { + const [bridge, frankData, local] = await Promise.all([ + fetchBridge(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchFrankfurter(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchLocalApi(from, to).catch((e) => ({ error: e?.message || String(e) })), + ]) + + const bridgeBuy = bridge?.buy_rate ? Number(bridge.buy_rate) : undefined + const bridgeMid = bridge?.midmarket_rate ? Number(bridge.midmarket_rate) : undefined + const bridgeSell = bridge?.sell_rate ? Number(bridge.sell_rate) : undefined + const frank = typeof frankData?.rate === 'number' ? frankData.rate : undefined + const frank995 = typeof frankData?.rate_995 === 'number' ? frankData.rate_995 : undefined + const localRate = typeof local?.rate === 'number' ? local.rate : undefined + + console.log(`\nPair: ${from} -> ${to}`) + console.table([ + { + source: 'Bridge', + buy: fmt(bridgeBuy), + mid: fmt(bridgeMid), + sell: fmt(bridgeSell), + note: bridge?.error || '', + }, + { + source: 'Frankfurter', + rate: fmt(frank), + rate_995: fmt(frank995), + note: frankData?.error || '', + }, + { + source: 'Local API', + rate: fmt(localRate), + note: local?.error || '', + }, + ]) + + // Delta analysis table + console.log(`\nDelta Analysis for ${from} -> ${to}:`) + console.table([ + { + comparison: 'Mid vs Frankfurt', + delta: bps(bridgeMid, frank), + }, + { + comparison: 'Mid vs Frankfurt×0.995', + delta: bps(bridgeMid, frank995), + }, + { + comparison: 'Sell vs Frankfurt×0.995', + delta: bps(bridgeSell, frank995), + }, + { + comparison: 'Sell vs Mid', + delta: bps(bridgeSell, bridgeMid), + }, + { + comparison: 'Buy vs Mid', + delta: bps(bridgeBuy, bridgeMid), + }, + { + comparison: 'Local vs Frankfurt×0.995', + delta: bps(localRate, frank995), + }, + ]) + } +} + +run().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts new file mode 100644 index 000000000..459f5b803 --- /dev/null +++ b/src/app/api/exchange-rate/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server' + +interface ExchangeRateResponse { + rate: number +} + +interface BridgeExchangeRateResponse { + midmarket_rate: string + buy_rate: string + sell_rate: string +} + +// Currency pairs that should use Bridge API (USD to these currencies only) +const BRIDGE_PAIRS = new Set(['USD-EUR', 'USD-MXN', 'USD-BRL']) + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const from = searchParams.get('from') + const to = searchParams.get('to') + + // Validate required parameters + if (!from || !to) { + return NextResponse.json({ error: 'Missing required parameters: from and to' }, { status: 400 }) + } + + const fromUc = from.toUpperCase() + const toUc = to.toUpperCase() + + // Same-currency pair: return 1:1 immediately + if (fromUc === toUc) { + return NextResponse.json( + { rate: 1 }, + { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + } + ) + } + + const pairKey = `${fromUc}-${toUc}` + const reversePairKey = `${toUc}-${fromUc}` + + // Check if we should use Bridge for this pair or its reverse + const shouldUseBridge = BRIDGE_PAIRS.has(pairKey) + const shouldUseBridgeReverse = BRIDGE_PAIRS.has(reversePairKey) + + if (shouldUseBridge || shouldUseBridgeReverse) { + // For Bridge pairs, we need to determine which rate to use + let bridgeResult + if (shouldUseBridge) { + // Direct pair (e.g., USD→EUR): use sell_rate + bridgeResult = await fetchFromBridge(fromUc, toUc, 'sell_rate', false) + } else { + // Reverse pair (e.g., EUR→USD): fetch USD→EUR and use buy_rate, then invert + bridgeResult = await fetchFromBridge(toUc, fromUc, 'buy_rate', true) + } + + if (bridgeResult) { + return bridgeResult + } + // Fall back to Frankfurter if Bridge fails + console.warn(`Bridge failed for ${pairKey}, falling back to Frankfurter`) + } + + // Use Frankfurter for all other pairs or as fallback + return await fetchFromFrankfurter(fromUc, toUc) + } catch (error) { + console.error('Exchange rate API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function fetchFromBridge( + from: string, + to: string, + rateType: 'buy_rate' | 'sell_rate', + shouldInvert: boolean +): Promise { + const bridgeAPIKey = process.env.BRIDGE_API_KEY + + if (!bridgeAPIKey) { + console.warn('Bridge API key not set') + return null + } + + try { + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + // Bridge expects header name 'Api-Key' + headers: { 'Api-Key': bridgeAPIKey }, + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Bridge API error: ${response.status} ${response.statusText}`) + return null + } + + const bridgeData: BridgeExchangeRateResponse = await response.json() + + // Validate the response structure + if (!bridgeData[rateType]) { + console.error(`Invalid Bridge response: missing ${rateType}`) + return null + } + + let rate = parseFloat(bridgeData[rateType]) + + // If we fetched the reverse pair (e.g., fetched USD→EUR for EUR→USD request), + // we need to invert the rate + if (shouldInvert) { + rate = 1 / rate + } + + const exchangeRate: ExchangeRateResponse = { + rate, + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) + } catch (error) { + console.error('Bridge API exception:', error) + return null + } +} + +async function fetchFromFrankfurter(from: string, to: string): Promise { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Frankfurter API error: ${response.status} ${response.statusText}`) + return NextResponse.json({ error: 'Failed to fetch exchange rates from API' }, { status: response.status }) + } + + const data = await response.json() + + const exchangeRate: ExchangeRateResponse = { + rate: data.rates[to] * 0.995, // Subtract 50bps + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) +} diff --git a/src/app/exchange/page.tsx b/src/app/exchange/page.tsx new file mode 100644 index 000000000..49143ff6f --- /dev/null +++ b/src/app/exchange/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import Layout from '@/components/Global/Layout' +import { NoFees } from '@/components/LandingPage' +import Footer from '@/components/LandingPage/Footer' +export default function ExchangePage() { + return ( + + +