Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
27d86b4d
Commit
27d86b4d
authored
Sep 12, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(calculator): integrate real-time market exchange rates with live badge and offline fallback
parent
c4c60432
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
298 additions
and
39 deletions
+298
-39
CalculatorModal.tsx
src/components/ui/CalculatorModal.tsx
+131
-22
en.json
src/i18n/locales/en.json
+7
-1
vi.json
src/i18n/locales/vi.json
+7
-1
ai-assistant.service.ts
src/services/ai-assistant.service.ts
+18
-0
currency.service.ts
src/services/currency.service.ts
+119
-15
ai.ts
src/types/ai.ts
+16
-0
No files found.
src/components/ui/CalculatorModal.tsx
View file @
27d86b4d
...
@@ -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,14 +188,72 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
...
@@ -177,14 +188,72 @@ 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
);
...
@@ -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
(
current
Rate
)
}
placeholder=
{
String
(
active
Rate
)
}
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
}
{
customRate
!==
undefined
?
(
<
span
className=
"ml-1 text-[10px] text-clay-expense font-normal"
>
<
span
className=
"ml-1 text-[10px] text-clay-expense font-normal"
>
(
{
t
(
"currency.custom"
)
||
"Tùy biến"
}
)
(
{
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
>
</
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
(
current
Rate
));
setCustomRateInput
(
String
(
active
Rate
));
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
>
...
...
src/i18n/locales/en.json
View file @
27d86b4d
...
@@ -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"
}
}
}
}
src/i18n/locales/vi.json
View file @
27d86b4d
...
@@ -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ệ"
}
}
}
}
src/services/ai-assistant.service.ts
View file @
27d86b4d
...
@@ -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
);
}
},
};
};
src/services/currency.service.ts
View file @
27d86b4d
...
@@ -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
:
25
450
,
USD
:
25
922.52
,
EUR
:
27600
,
EUR
:
30067.05
,
JPY
:
16
5
,
JPY
:
16
8.3
,
KRW
:
19
,
KRW
:
19
.3
,
CNY
:
3
550
,
CNY
:
3
864.5
,
GBP
:
3
2800
,
GBP
:
3
5022.8
,
THB
:
7
40
,
THB
:
7
84.2
,
SGD
:
19400
,
SGD
:
20448.9
,
AUD
:
1
6800
,
AUD
:
1
8591.4
,
CAD
:
18
500
,
CAD
:
18
710.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)"
,
};
}
src/types/ai.ts
View file @
27d86b4d
...
@@ -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
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment