Commit 27d86b4d authored by ThinhNC's avatar ThinhNC

feat(calculator): integrate real-time market exchange rates with live badge and offline fallback

parent c4c60432
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
getExchangeRate, getExchangeRate,
getCachedRates, getCachedRates,
fetchLatestRates, fetchLatestRates,
fetchLiveExchangeRate,
} from "@/services/currency.service"; } from "@/services/currency.service";
import { ExchangeRateMap } from "@/types/currency"; import { ExchangeRateMap } from "@/types/currency";
...@@ -149,6 +150,14 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -149,6 +150,14 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
const [customRate, setCustomRate] = useState<number | undefined>(undefined); const [customRate, setCustomRate] = useState<number | undefined>(undefined);
const [isEditingRate, setIsEditingRate] = useState<boolean>(false); const [isEditingRate, setIsEditingRate] = useState<boolean>(false);
const [customRateInput, setCustomRateInput] = useState<string>(""); const [customRateInput, setCustomRateInput] = useState<string>("");
const [isLoadingRate, setIsLoadingRate] = useState<boolean>(false);
const [liveRateInfo, setLiveRateInfo] = useState<{
rate: number;
from: string;
to: string;
isLive: boolean;
note?: string;
} | null>(null);
// Initialize or reset when modal opens // Initialize or reset when modal opens
useEffect(() => { useEffect(() => {
...@@ -167,6 +176,8 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -167,6 +176,8 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
setFromCurrency(def === "USD" ? "VND" : "USD"); setFromCurrency(def === "USD" ? "VND" : "USD");
setCustomRate(undefined); setCustomRate(undefined);
setIsEditingRate(false); setIsEditingRate(false);
setLiveRateInfo(null);
setIsLoadingRate(false);
// Refresh rates in background // Refresh rates in background
fetchLatestRates().then((latest) => { fetchLatestRates().then((latest) => {
...@@ -177,15 +188,73 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -177,15 +188,73 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
const currentRate = getExchangeRate(fromCurrency, toCurrency, rates, customRate); const baselineRate = getExchangeRate(fromCurrency, toCurrency, rates, customRate);
const activeRate =
customRate !== undefined
? customRate
: liveRateInfo &&
liveRateInfo.from === fromCurrency &&
liveRateInfo.to === toCurrency
? liveRateInfo.rate
: baselineRate;
const currentNum = parseFloat(display) || 0; const currentNum = parseFloat(display) || 0;
const conversionResult = convertCurrency({ const conversionResult = convertCurrency({
amount: currentNum, amount: currentNum,
from: fromCurrency, from: fromCurrency,
to: toCurrency, to: toCurrency,
customRate, customRate: activeRate,
}); });
const fetchRate = async (from: string, to: string) => {
const fromCode = from.toUpperCase().trim();
const toCode = to.toUpperCase().trim();
if (fromCode === toCode) {
setLiveRateInfo({
rate: 1,
from: fromCode,
to: toCode,
isLive: true,
note: t("currency.sameCurrencyNote") || "Tỷ giá giữa cùng một loại tiền tệ",
});
return;
}
setIsLoadingRate(true);
try {
const result = await fetchLiveExchangeRate(fromCode, toCode);
setLiveRateInfo({
rate: result.rate,
from: fromCode,
to: toCode,
isLive: result.isLive,
note: result.note,
});
} catch (err) {
console.warn("Could not fetch live exchange rate, fallback to local rate:", err);
setLiveRateInfo({
rate: baselineRate,
from: fromCode,
to: toCode,
isLive: false,
note: t("currency.estimated") || "Cơ sở",
});
} finally {
setIsLoadingRate(false);
}
};
const handleToggleConverter = () => {
const nextState = !isConverterOpen;
setIsConverterOpen(nextState);
// Trigger live rate fetch when user opens the currency converter
if (nextState) {
fetchRate(fromCurrency, toCurrency);
}
};
const handleApplyConversion = () => { const handleApplyConversion = () => {
const convertedStr = String(conversionResult.toAmount); const convertedStr = String(conversionResult.toAmount);
setDisplay(convertedStr); setDisplay(convertedStr);
...@@ -194,10 +263,13 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -194,10 +263,13 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
}; };
const handleSwapCurrencies = () => { const handleSwapCurrencies = () => {
setFromCurrency(toCurrency); const nextFrom = toCurrency;
setToCurrency(fromCurrency); const nextTo = fromCurrency;
setFromCurrency(nextFrom);
setToCurrency(nextTo);
setCustomRate(undefined); setCustomRate(undefined);
setIsEditingRate(false); setIsEditingRate(false);
fetchRate(nextFrom, nextTo);
}; };
const handleSaveCustomRate = () => { const handleSaveCustomRate = () => {
...@@ -402,11 +474,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -402,11 +474,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Currency Converter Toggle Button */} {/* Currency Converter Toggle Button (Next to X) */}
<button <button
id="btn-toggle-converter" id="btn-toggle-converter"
type="button" type="button"
onClick={() => setIsConverterOpen((prev) => !prev)} onClick={handleToggleConverter}
title={t("currency.converterTitle") || "Chuyển đổi ngoại tệ"} title={t("currency.converterTitle") || "Chuyển đổi ngoại tệ"}
aria-label={t("currency.converterTitle") || "Chuyển đổi ngoại tệ"} aria-label={t("currency.converterTitle") || "Chuyển đổi ngoại tệ"}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out ${ className={`w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out ${
...@@ -415,7 +487,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -415,7 +487,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
: "bg-clay-bg text-clay-primary border border-clay-highlight/50 shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed hover:scale-105 active:scale-95" : "bg-clay-bg text-clay-primary border border-clay-highlight/50 shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed hover:scale-105 active:scale-95"
}`} }`}
> >
<ExchangeIcon size={18} /> <ExchangeIcon size={18} className={isLoadingRate ? "animate-spin" : ""} />
</button> </button>
{/* Close Button */} {/* Close Button */}
...@@ -441,9 +513,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -441,9 +513,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
<select <select
value={fromCurrency} value={fromCurrency}
onChange={(e) => { onChange={(e) => {
setFromCurrency(e.target.value); const newFrom = e.target.value;
setFromCurrency(newFrom);
setCustomRate(undefined); setCustomRate(undefined);
setIsEditingRate(false); setIsEditingRate(false);
fetchRate(newFrom, toCurrency);
}} }}
className="w-full bg-clay-surface text-clay-text font-baloo font-bold text-xs rounded-clay-sm border border-clay-highlight/60 shadow-clay-raised px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-clay-primary/50 cursor-pointer" className="w-full bg-clay-surface text-clay-text font-baloo font-bold text-xs rounded-clay-sm border border-clay-highlight/60 shadow-clay-raised px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-clay-primary/50 cursor-pointer"
> >
...@@ -471,9 +545,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -471,9 +545,11 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
<select <select
value={toCurrency} value={toCurrency}
onChange={(e) => { onChange={(e) => {
setToCurrency(e.target.value); const newTo = e.target.value;
setToCurrency(newTo);
setCustomRate(undefined); setCustomRate(undefined);
setIsEditingRate(false); setIsEditingRate(false);
fetchRate(fromCurrency, newTo);
}} }}
className="w-full bg-clay-surface text-clay-text font-baloo font-bold text-xs rounded-clay-sm border border-clay-highlight/60 shadow-clay-raised px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-clay-primary/50 cursor-pointer" className="w-full bg-clay-surface text-clay-text font-baloo font-bold text-xs rounded-clay-sm border border-clay-highlight/60 shadow-clay-raised px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-clay-primary/50 cursor-pointer"
> >
...@@ -490,7 +566,12 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -490,7 +566,12 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
<div className="flex items-center justify-between pt-1 border-t border-clay-text-muted/10"> <div className="flex items-center justify-between pt-1 border-t border-clay-text-muted/10">
<div className="flex items-center gap-1.5 text-[11px] text-clay-text-muted"> <div className="flex items-center gap-1.5 text-[11px] text-clay-text-muted">
<span>{t("currency.exchangeRate") || "Tỷ giá"}:</span> <span>{t("currency.exchangeRate") || "Tỷ giá"}:</span>
{isEditingRate ? ( {isLoadingRate ? (
<span className="text-[11px] text-clay-primary flex items-center gap-1 font-semibold animate-pulse">
<span className="inline-block w-2.5 h-2.5 border-2 border-clay-primary/30 border-t-clay-primary rounded-full animate-spin"></span>
{t("currency.updatingRate") || "Đang cập nhật tỷ giá..."}
</span>
) : isEditingRate ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
type="number" type="number"
...@@ -498,7 +579,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -498,7 +579,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
min="0" min="0"
value={customRateInput} value={customRateInput}
onChange={(e) => setCustomRateInput(e.target.value)} onChange={(e) => setCustomRateInput(e.target.value)}
placeholder={String(currentRate)} placeholder={String(activeRate)}
className="w-20 bg-clay-surface text-clay-text font-baloo font-semibold text-xs rounded px-1.5 py-0.5 border border-clay-highlight shadow-clay-pressed focus:outline-none" className="w-20 bg-clay-surface text-clay-text font-baloo font-semibold text-xs rounded px-1.5 py-0.5 border border-clay-highlight shadow-clay-pressed focus:outline-none"
/> />
<button <button
...@@ -510,22 +591,42 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -510,22 +591,42 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
</button> </button>
</div> </div>
) : ( ) : (
<span className="font-baloo font-bold text-clay-text text-xs"> <div className="flex items-center gap-1">
1 {fromCurrency} = {formatRateDisplay(currentRate, intlLocale)} {toCurrency} <span className="font-baloo font-bold text-clay-text text-xs flex items-center gap-1">
{customRate !== undefined && ( 1 {fromCurrency} = {formatRateDisplay(activeRate, intlLocale)} {toCurrency}
<span className="ml-1 text-[10px] text-clay-expense font-normal"> {customRate !== undefined ? (
({t("currency.custom") || "Tùy biến"}) <span className="ml-1 text-[10px] text-clay-expense font-normal">
</span> ({t("currency.custom") || "Tùy biến"})
)} </span>
</span> ) : liveRateInfo && liveRateInfo.from === fromCurrency && liveRateInfo.to === toCurrency && liveRateInfo.isLive ? (
<span className="inline-flex items-center px-1.5 py-0.2 rounded text-[9px] font-bold bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
{t("currency.liveBadge") || "Thị trường"}
</span>
) : (
<span className="ml-1 text-[10px] text-clay-text-muted/70 font-normal">
({t("currency.estimated") || "Cơ sở"})
</span>
)}
</span>
<button
type="button"
onClick={() => fetchRate(fromCurrency, toCurrency)}
disabled={isLoadingRate}
title={t("currency.refreshRate") || "Cập nhật tỷ giá thị trường"}
aria-label={t("currency.refreshRate") || "Cập nhật tỷ giá thị trường"}
className="w-5 h-5 rounded-full flex items-center justify-center text-clay-text-muted hover:text-clay-primary hover:bg-clay-surface active:scale-95 disabled:opacity-40 transition-all ml-0.5"
>
<ExchangeIcon size={12} className={isLoadingRate ? "animate-spin" : ""} />
</button>
</div>
)} )}
</div> </div>
{!isEditingRate && ( {!isEditingRate && !isLoadingRate && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setCustomRateInput(String(currentRate)); setCustomRateInput(String(activeRate));
setIsEditingRate(true); setIsEditingRate(true);
}} }}
className="text-[11px] text-clay-primary hover:text-clay-primary-dark font-medium underline transition-colors" className="text-[11px] text-clay-primary hover:text-clay-primary-dark font-medium underline transition-colors"
...@@ -535,6 +636,13 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -535,6 +636,13 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
)} )}
</div> </div>
{/* Live Feed Note if available */}
{liveRateInfo && liveRateInfo.from === fromCurrency && liveRateInfo.to === toCurrency && liveRateInfo.note && (
<div className="text-[10px] text-clay-text-muted italic bg-clay-surface/50 px-2 py-0.5 rounded border border-clay-highlight/20 truncate">
💡 {liveRateInfo.note}
</div>
)}
{/* Live Conversion Preview & Apply Button */} {/* Live Conversion Preview & Apply Button */}
<div className="flex items-center justify-between gap-2 pt-1 border-t border-clay-text-muted/10 bg-clay-surface/70 rounded-clay-sm p-2 border border-clay-highlight/40"> <div className="flex items-center justify-between gap-2 pt-1 border-t border-clay-text-muted/10 bg-clay-surface/70 rounded-clay-sm p-2 border border-clay-highlight/40">
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
...@@ -549,7 +657,8 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -549,7 +657,8 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
id="btn-apply-converted-currency" id="btn-apply-converted-currency"
type="button" type="button"
onClick={handleApplyConversion} onClick={handleApplyConversion}
className="px-2.5 py-1.5 rounded-clay-sm bg-clay-primary text-clay-on-primary font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:scale-95 transition-all duration-150 shrink-0" disabled={isLoadingRate}
className="px-2.5 py-1.5 rounded-clay-sm bg-clay-primary text-clay-on-primary font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:scale-95 disabled:opacity-60 disabled:pointer-events-none transition-all duration-150 shrink-0"
> >
{t("currency.apply") || "Áp dụng"} {t("currency.apply") || "Áp dụng"}
</button> </button>
......
...@@ -2210,6 +2210,12 @@ ...@@ -2210,6 +2210,12 @@
"thb": "Thai Baht", "thb": "Thai Baht",
"sgd": "Singapore Dollar", "sgd": "Singapore Dollar",
"aud": "Australian Dollar", "aud": "Australian Dollar",
"cad": "Canadian Dollar" "cad": "Canadian Dollar",
"updatingRate": "Updating market rate...",
"liveBadge": "Live Market",
"estimated": "Baseline",
"refreshRate": "Refresh market rate",
"sameCurrencyNote": "Exchange rate for identical currencies"
} }
} }
...@@ -2311,6 +2311,12 @@ ...@@ -2311,6 +2311,12 @@
"thb": "Baht Thái", "thb": "Baht Thái",
"sgd": "Đô la Singapore", "sgd": "Đô la Singapore",
"aud": "Đô la Úc", "aud": "Đô la Úc",
"cad": "Đô la Canada" "cad": "Đô la Canada",
"updatingRate": "Đang cập nhật tỷ giá...",
"liveBadge": "Thị trường",
"estimated": "Cơ sở",
"refreshRate": "Cập nhật tỷ giá thị trường",
"sameCurrencyNote": "Tỷ giá giữa cùng một loại tiền tệ"
} }
} }
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
CategorizeTransactionData, CategorizeTransactionData,
ExtractReceiptInput, ExtractReceiptInput,
ExtractReceiptData, ExtractReceiptData,
AIExchangeRateInput,
AIExchangeRateData,
AIServiceResponse, AIServiceResponse,
} from "@/types/ai"; } from "@/types/ai";
...@@ -142,4 +144,20 @@ export const aiAssistantService = { ...@@ -142,4 +144,20 @@ export const aiAssistantService = {
return handleAIError(error); return handleAIError(error);
} }
}, },
async getExchangeRate(
input: AIExchangeRateInput
): Promise<AIServiceResponse<AIExchangeRateData>> {
try {
const response = await apiClient.post<AIServiceResponse<AIExchangeRateData>>(
"/ai-assistant/exchange-rate",
input,
{ timeout: 30000 }
);
return response.data;
} catch (error) {
return handleAIError(error);
}
},
}; };
...@@ -26,16 +26,16 @@ export const POPULAR_CURRENCIES: CurrencyItem[] = [ ...@@ -26,16 +26,16 @@ export const POPULAR_CURRENCIES: CurrencyItem[] = [
*/ */
export const DEFAULT_VND_RATES: ExchangeRateMap = { export const DEFAULT_VND_RATES: ExchangeRateMap = {
VND: 1, VND: 1,
USD: 25450, USD: 25922.52,
EUR: 27600, EUR: 30067.05,
JPY: 165, JPY: 168.3,
KRW: 19, KRW: 19.3,
CNY: 3550, CNY: 3864.5,
GBP: 32800, GBP: 35022.8,
THB: 740, THB: 784.2,
SGD: 19400, SGD: 20448.9,
AUD: 16800, AUD: 18591.4,
CAD: 18500, CAD: 18710.2,
}; };
const CACHE_KEY = "finwise.currency_rates"; const CACHE_KEY = "finwise.currency_rates";
...@@ -132,7 +132,39 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -132,7 +132,39 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
} }
} }
// Try fetching USD-based rates from free open API // 1. Try FloatRates (interbank feed)
try {
const response = await fetch("https://www.floatrates.com/daily/usd.json");
if (response.ok) {
const data = await response.json();
if (data && data.vnd?.rate) {
const usdVnd = parseFloat(String(data.vnd.rate));
if (!isNaN(usdVnd) && usdVnd > 0) {
const newVndRates: ExchangeRateMap = { VND: 1, USD: usdVnd };
for (const [codeLower, info] of Object.entries(data)) {
const code = codeLower.toUpperCase();
const item = info as { rate?: string | number; inverseRate?: string | number };
if (item?.rate) {
const foreignRateAgainstUsd = parseFloat(String(item.rate));
if (!isNaN(foreignRateAgainstUsd) && foreignRateAgainstUsd > 0) {
newVndRates[code] = Math.round((usdVnd / foreignRateAgainstUsd) * 100) / 100;
}
}
}
const merged = { ...DEFAULT_VND_RATES, ...newVndRates };
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {}
return merged;
}
}
}
} catch {
// Continue to open.er-api fallback
}
// 2. Try fetching USD-based rates from open exchange API
const response = await fetch("https://open.er-api.com/v6/latest/USD"); const response = await fetch("https://open.er-api.com/v6/latest/USD");
if (!response.ok) { if (!response.ok) {
return getCachedRates(); return getCachedRates();
...@@ -148,7 +180,6 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -148,7 +180,6 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
for (const [code, rateAgainstUsd] of Object.entries(usdRates)) { for (const [code, rateAgainstUsd] of Object.entries(usdRates)) {
const rateNum = Number(rateAgainstUsd); const rateNum = Number(rateAgainstUsd);
if (rateNum > 0) { if (rateNum > 0) {
// 1 USD = rateNum [CODE] => 1 [CODE] = (usdVnd / rateNum) VND
newVndRates[code] = Math.round((usdVnd / rateNum) * 100) / 100; newVndRates[code] = Math.round((usdVnd / rateNum) * 100) / 100;
} }
} }
...@@ -157,9 +188,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -157,9 +188,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
try { try {
localStorage.setItem(CACHE_KEY, JSON.stringify(merged)); localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now)); localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch { } catch {}
// Storage might be full
}
return merged; return merged;
} }
} catch { } catch {
...@@ -168,3 +197,78 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -168,3 +197,78 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
return getCachedRates(); return getCachedRates();
} }
/**
* Fetch real-time live exchange rate for a specific pair from interbank feed (FloatRates / Open Exchange Rates)
* with robust offline fallback to cached rates. No AI tokens consumed.
*/
export async function fetchLiveExchangeRate(
from: string,
to: string
): Promise<{ rate: number; isLive: boolean; note?: string }> {
const fromCode = from.toUpperCase().trim();
const toCode = to.toUpperCase().trim();
if (fromCode === toCode) {
return { rate: 1, isLive: true, note: "Tỷ giá giữa cùng một loại tiền tệ" };
}
// 1. Try FloatRates directly for real-time interbank market rate
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://www.floatrates.com/daily/${fromCode.toLowerCase()}.json`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const target = data[toCode.toLowerCase()];
if (target?.rate) {
const rate = parseFloat(String(target.rate));
if (!isNaN(rate) && rate > 0) {
return {
rate,
isLive: true,
note: "Tỷ giá thị trường liên ngân hàng (FloatRates)",
};
}
}
}
} catch {
// Ignore network error and continue
}
// 2. Try Open Exchange Rates API
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3500);
const res = await fetch(`https://open.er-api.com/v6/latest/${fromCode}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
const data = await res.json();
const targetRate = data.rates?.[toCode];
if (typeof targetRate === "number" && targetRate > 0) {
return {
rate: targetRate,
isLive: true,
note: "Tỷ giá thị trường mở (Open Exchange Rates)",
};
}
}
} catch {
// Ignore network error
}
// 3. Fallback to cached or DEFAULT_VND_RATES
const cachedRates = getCachedRates();
const fallbackRate = getExchangeRate(fromCode, toCode, cachedRates);
return {
rate: fallbackRate,
isLive: false,
note: "Tỷ giá thị trường cơ sở (ngoại tuyến)",
};
}
...@@ -160,3 +160,19 @@ export interface AIRateLimitError { ...@@ -160,3 +160,19 @@ export interface AIRateLimitError {
code: string; code: string;
retryAfterSeconds: number; retryAfterSeconds: number;
} }
export interface AIExchangeRateInput {
from: string;
to: string;
amount?: number;
}
export interface AIExchangeRateData {
from: string;
to: string;
rate: number;
amount: number;
convertedAmount: number;
formattedRate?: string;
note?: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment