Commit c9f3261a authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/calculator-currency-converter' into 'develop'

feat(calculator): integrate currency converter and refine modal positioning & branding

See merge request !40
parents 53fa49c9 c4c60432
...@@ -69,7 +69,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -69,7 +69,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Dev server dùng cổng 13579 cho nội dung app và cổng 13580 cho khung mô phỏng; `index.html` tiếp tục nằm ở root để tránh iframe trả 404 hoặc màn hình đen. - Dev server dùng cổng 13579 cho nội dung app và cổng 13580 cho khung mô phỏng; `index.html` tiếp tục nằm ở root để tránh iframe trả 404 hoặc màn hình đen.
- Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử. - Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh. - Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang, đính kèm hóa đơn và tích hợp nút máy tính tính toán số tiền nhanh (+, −, ×, ÷, 000) ngay giữa Loại giao dịch và Số tiền. - Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang, đính kèm hóa đơn và tích hợp nút máy tính tính toán số tiền nhanh (+, −, ×, ÷, 000, kèm tính năng chuyển đổi ngoại tệ linh hoạt hỗ trợ các loại tiền phổ biến VND, USD, EUR, JPY, KRW, CNY, GBP, THB, SGD, AUD, CAD cùng tùy chỉnh tỷ giá) ngay giữa Loại giao dịch và Số tiền.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán. - Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh. - Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh. - Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
......
...@@ -14,10 +14,10 @@ ...@@ -14,10 +14,10 @@
<meta name="theme-color" content="#F3F0FA" /> <meta name="theme-color" content="#F3F0FA" />
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" /> <meta name="msapplication-tap-highlight" content="no" />
<title>Sổ tay Chi tiêu & Báo cáo Tài chính</title> <title>FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/src/static/logo.png" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> <link rel="shortcut icon" type="image/png" href="/src/static/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/src/static/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
......
...@@ -51,10 +51,50 @@ export const DocumentTitle: React.FC = () => { ...@@ -51,10 +51,50 @@ export const DocumentTitle: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
useEffect(() => { useEffect(() => {
// Dynamically set / update favicon to guarantee browser tab displays FinWise logo // Resolve absolute URL for FinWise logo
const fullLogoUrl = new URL(finwiseLogo, window.location.href).href;
const updateFaviconInDoc = (doc: Document) => {
try {
const iconRels = ["icon", "shortcut icon", "apple-touch-icon"]; const iconRels = ["icon", "shortcut icon", "apple-touch-icon"];
iconRels.forEach((rel) => { iconRels.forEach((rel) => {
let link: HTMLLinkElement | null = document.querySelector(`link[rel='${rel}']`); let link: HTMLLinkElement | null = doc.querySelector(`link[rel='${rel}']`);
if (!link) {
link = doc.createElement("link");
link.rel = rel;
doc.head.appendChild(link);
}
if (rel !== "apple-touch-icon") {
link.type = "image/png";
}
link.href = fullLogoUrl;
});
} catch {
// Fallback for security restrictions
}
};
// 1. Update current frame document
updateFaviconInDoc(document);
// 2. If running inside Zalo Mini App Simulator (parent iframe), sync to parent window
if (window.parent && window.parent !== window) {
try {
// Direct update if same origin
if (window.parent.document) {
updateFaviconInDoc(window.parent.document);
}
} catch {
// Different origin: send custom message to ZMP simulator
try {
window.parent.postMessage(
{
type: "custom",
data: `
try {
const iconRels = ["icon", "shortcut icon", "apple-touch-icon"];
iconRels.forEach(function(rel) {
var link = document.querySelector("link[rel='" + rel + "']");
if (!link) { if (!link) {
link = document.createElement("link"); link = document.createElement("link");
link.rel = rel; link.rel = rel;
...@@ -63,13 +103,46 @@ export const DocumentTitle: React.FC = () => { ...@@ -63,13 +103,46 @@ export const DocumentTitle: React.FC = () => {
if (rel !== "apple-touch-icon") { if (rel !== "apple-touch-icon") {
link.type = "image/png"; link.type = "image/png";
} }
link.href = finwiseLogo; link.href = "${fullLogoUrl}";
}); });
} catch (e) {}
`,
},
"*"
);
} catch {
// Ignore
}
}
}
}, []); }, []);
useEffect(() => { useEffect(() => {
const key = PAGE_TITLES.find((page) => page.matches(pathname))?.key; const key = PAGE_TITLES.find((page) => page.matches(pathname))?.key;
document.title = `${key ? t(key) : t("document.default")} | ${APP_NAME}`; const fullTitle = `${key ? t(key) : t("document.default")} | ${APP_NAME}`;
document.title = fullTitle;
// If running in ZMP simulator iframe, sync the document title to the parent simulator
if (window.parent && window.parent !== window) {
try {
if (window.parent.document) {
window.parent.document.title = fullTitle;
}
} catch {
// Different origin: use simulator config-title message
try {
window.parent.postMessage(
{
type: "config-title",
data: fullTitle,
},
"*"
);
} catch {
// Ignore
}
}
}
}, [pathname, t]); }, [pathname, t]);
return null; return null;
......
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { CalculatorIcon, CheckIcon, CloseIcon } from "@/components/ui/icons"; import { CalculatorIcon, CheckIcon, CloseIcon, ExchangeIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import {
POPULAR_CURRENCIES,
convertCurrency,
getExchangeRate,
getCachedRates,
fetchLatestRates,
} from "@/services/currency.service";
import { ExchangeRateMap } from "@/types/currency";
export interface CalculatorButtonProps { export interface CalculatorButtonProps {
onClick: () => void; onClick: () => void;
...@@ -44,6 +52,7 @@ export interface CalculatorModalProps { ...@@ -44,6 +52,7 @@ export interface CalculatorModalProps {
onApply: (amount: string) => void; onApply: (amount: string) => void;
initialAmount?: string; initialAmount?: string;
title?: string; title?: string;
defaultCurrency?: string;
} }
type Operator = "+" | "-" | "*" | "/"; type Operator = "+" | "-" | "*" | "/";
...@@ -86,6 +95,19 @@ function formatDisplayValue(raw: string, locale: string): string { ...@@ -86,6 +95,19 @@ function formatDisplayValue(raw: string, locale: string): string {
return decPart !== undefined ? `${formattedInt}${decimal}${decPart}` : formattedInt; return decPart !== undefined ? `${formattedInt}${decimal}${decPart}` : formattedInt;
} }
function formatRateDisplay(rate: number, locale: string): string {
if (!rate || isNaN(rate)) return "1";
const { group, decimal } = getNumberSeparators(locale);
if (rate >= 1) {
const rounded = Math.round(rate * 100) / 100;
const parts = String(rounded).split(".");
const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, group);
return parts[1] !== undefined ? `${intPart}${decimal}${parts[1]}` : intPart;
}
// Rate < 1, display up to 4 significant decimal places
return String(Number(rate.toFixed(4))).replace(".", decimal);
}
function opSymbol(op: Operator): string { function opSymbol(op: Operator): string {
switch (op) { switch (op) {
case "+": case "+":
...@@ -105,6 +127,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -105,6 +127,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
onApply, onApply,
initialAmount = "", initialAmount = "",
title, title,
defaultCurrency = "VND",
}) => { }) => {
const { t, intlLocale } = useI18n(); const { t, intlLocale } = useI18n();
...@@ -115,6 +138,18 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -115,6 +138,18 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
const [waitingForOperand, setWaitingForOperand] = useState<boolean>(false); const [waitingForOperand, setWaitingForOperand] = useState<boolean>(false);
const [isCalculated, setIsCalculated] = useState<boolean>(false); const [isCalculated, setIsCalculated] = useState<boolean>(false);
// Currency Converter states
const [rates, setRates] = useState<ExchangeRateMap>(() => getCachedRates());
const [isConverterOpen, setIsConverterOpen] = useState<boolean>(false);
const normalizedDefault = (defaultCurrency || "VND").toUpperCase();
const [toCurrency, setToCurrency] = useState<string>(normalizedDefault);
const [fromCurrency, setFromCurrency] = useState<string>(
normalizedDefault === "USD" ? "VND" : "USD"
);
const [customRate, setCustomRate] = useState<number | undefined>(undefined);
const [isEditingRate, setIsEditingRate] = useState<boolean>(false);
const [customRateInput, setCustomRateInput] = useState<string>("");
// Initialize or reset when modal opens // Initialize or reset when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
...@@ -125,11 +160,56 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -125,11 +160,56 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
setOperator(null); setOperator(null);
setWaitingForOperand(false); setWaitingForOperand(false);
setIsCalculated(false); setIsCalculated(false);
// Reset converter state on open
const def = (defaultCurrency || "VND").toUpperCase();
setToCurrency(def);
setFromCurrency(def === "USD" ? "VND" : "USD");
setCustomRate(undefined);
setIsEditingRate(false);
// Refresh rates in background
fetchLatestRates().then((latest) => {
if (latest) setRates(latest);
});
} }
}, [isOpen, initialAmount]); }, [isOpen, initialAmount, defaultCurrency]);
if (!isOpen) return null; if (!isOpen) return null;
const currentRate = getExchangeRate(fromCurrency, toCurrency, rates, customRate);
const currentNum = parseFloat(display) || 0;
const conversionResult = convertCurrency({
amount: currentNum,
from: fromCurrency,
to: toCurrency,
customRate,
});
const handleApplyConversion = () => {
const convertedStr = String(conversionResult.toAmount);
setDisplay(convertedStr);
setExpression(`${formatDisplayValue(display, intlLocale)} ${fromCurrency}${toCurrency}`);
setIsCalculated(true);
};
const handleSwapCurrencies = () => {
setFromCurrency(toCurrency);
setToCurrency(fromCurrency);
setCustomRate(undefined);
setIsEditingRate(false);
};
const handleSaveCustomRate = () => {
const parsed = parseFloat(customRateInput);
if (parsed > 0) {
setCustomRate(parsed);
} else {
setCustomRate(undefined);
}
setIsEditingRate(false);
};
const calculateResult = (prev: number, current: number, op: Operator): number => { const calculateResult = (prev: number, current: number, op: Operator): number => {
let res = 0; let res = 0;
switch (op) { switch (op) {
...@@ -303,7 +383,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -303,7 +383,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
}; };
return ( return (
<div className="fixed inset-0 z-[1050] flex items-end justify-center p-0 sm:items-center sm:p-4"> <div className="fixed inset-0 z-[1050] flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity" className="absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
...@@ -311,7 +391,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -311,7 +391,7 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
/> />
{/* Calculator Container */} {/* Calculator Container */}
<div className="relative w-full max-w-xs bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-5 flex flex-col gap-3.5 select-none animate-modal-content-in"> <div className="relative w-full max-w-[340px] sm:max-w-sm my-auto bg-clay-surface rounded-clay-lg shadow-clay-modal border border-clay-highlight/60 p-4 sm:p-5 flex flex-col gap-3 select-none animate-modal-content-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between pb-1 border-b border-clay-text-muted/10"> <div className="flex items-center justify-between pb-1 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
...@@ -320,20 +400,170 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({ ...@@ -320,20 +400,170 @@ export const CalculatorModal: React.FC<CalculatorModalProps> = ({
{title || t("transaction.calculatorTitle") || "Máy tính giao dịch"} {title || t("transaction.calculatorTitle") || "Máy tính giao dịch"}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-1.5">
{/* Currency Converter Toggle Button */}
<button
id="btn-toggle-converter"
type="button"
onClick={() => setIsConverterOpen((prev) => !prev)}
title={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 ${
isConverterOpen
? "bg-clay-primary text-clay-on-primary shadow-clay-pressed 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} />
</button>
{/* Close Button */}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label={t("accessibility.closeModal") || "Đóng"} aria-label={t("accessibility.closeModal") || "Đóng"}
className="w-7 h-7 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px] transition-all duration-150" className="w-8 h-8 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
> >
<CloseIcon size={16} /> <CloseIcon size={16} />
</button> </button>
</div> </div>
</div>
{/* Currency Converter Section (Expandable) */}
{isConverterOpen && (
<div className="bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col gap-2 text-xs font-nunito animate-modal-content-in">
{/* Currencies Selector Row */}
<div className="flex items-center justify-between gap-2">
{/* From Currency */}
<div className="flex-1 flex flex-col gap-1 min-w-0">
<span className="text-[11px] font-semibold text-clay-text-muted">{t("currency.from") || "Từ"}</span>
<select
value={fromCurrency}
onChange={(e) => {
setFromCurrency(e.target.value);
setCustomRate(undefined);
setIsEditingRate(false);
}}
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"
>
{POPULAR_CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code} ({c.symbol})
</option>
))}
</select>
</div>
{/* Swap Button */}
<button
type="button"
onClick={handleSwapCurrencies}
title={t("currency.swap") || "Đảo chiều"}
className="mt-4 w-7 h-7 rounded-full flex items-center justify-center bg-clay-surface 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 transition-all duration-150 shrink-0"
>
<ExchangeIcon size={14} />
</button>
{/* To Currency */}
<div className="flex-1 flex flex-col gap-1 min-w-0">
<span className="text-[11px] font-semibold text-clay-text-muted">{t("currency.to") || "Sang"}</span>
<select
value={toCurrency}
onChange={(e) => {
setToCurrency(e.target.value);
setCustomRate(undefined);
setIsEditingRate(false);
}}
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"
>
{POPULAR_CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code} ({c.symbol})
</option>
))}
</select>
</div>
</div>
{/* Exchange Rate & Edit Rate Row */}
<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">
<span>{t("currency.exchangeRate") || "Tỷ giá"}:</span>
{isEditingRate ? (
<div className="flex items-center gap-1">
<input
type="number"
step="any"
min="0"
value={customRateInput}
onChange={(e) => setCustomRateInput(e.target.value)}
placeholder={String(currentRate)}
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
type="button"
onClick={handleSaveCustomRate}
className="text-[10px] text-clay-primary font-bold hover:underline"
>
{t("common.save") || "Lưu"}
</button>
</div>
) : (
<span className="font-baloo font-bold text-clay-text text-xs">
1 {fromCurrency} = {formatRateDisplay(currentRate, intlLocale)} {toCurrency}
{customRate !== undefined && (
<span className="ml-1 text-[10px] text-clay-expense font-normal">
({t("currency.custom") || "Tùy biến"})
</span>
)}
</span>
)}
</div>
{!isEditingRate && (
<button
type="button"
onClick={() => {
setCustomRateInput(String(currentRate));
setIsEditingRate(true);
}}
className="text-[11px] text-clay-primary hover:text-clay-primary-dark font-medium underline transition-colors"
>
{t("currency.customRate") || "Sửa tỷ giá"}
</button>
)}
</div>
{/* 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 flex-col min-w-0">
<span className="text-[10px] text-clay-text-muted">
{formatDisplayValue(display, intlLocale)} {fromCurrency}
</span>
<span className="font-baloo font-bold text-sm text-clay-income truncate">
{formatDisplayValue(String(conversionResult.toAmount), intlLocale)} {toCurrency}
</span>
</div>
<button
id="btn-apply-converted-currency"
type="button"
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"
>
{t("currency.apply") || "Áp dụng"}
</button>
</div>
</div>
)}
{/* Display Screen */} {/* Display Screen */}
<div className="bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col justify-center min-h-[68px] text-right"> <div className="bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col justify-center min-h-[68px] text-right">
<div className="text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4 truncate"> <div className="flex items-center justify-between text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4">
{expression || "\u00A0"} <span className="text-[10px] font-baloo font-bold text-clay-primary/90 bg-clay-surface/80 px-1.5 py-0.5 rounded border border-clay-highlight/50 shrink-0">
{isConverterOpen ? fromCurrency : (toCurrency || "VND")}
</span>
<span className="truncate ml-2">{expression || "\u00A0"}</span>
</div> </div>
<div className="text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate"> <div className="text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate">
{formatDisplayValue(display, intlLocale)} {formatDisplayValue(display, intlLocale)}
......
...@@ -354,4 +354,23 @@ export const CalculatorIcon: React.FC<IconProps> = ({ size = 24, ...props }) => ...@@ -354,4 +354,23 @@ export const CalculatorIcon: React.FC<IconProps> = ({ size = 24, ...props }) =>
</svg> </svg>
); );
// Currency Exchange / Swap Icon
export const ExchangeIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M7 10h14l-4-4" />
<path d="M17 14H3l4 4" />
</svg>
);
...@@ -2188,5 +2188,28 @@ ...@@ -2188,5 +2188,28 @@
}, },
"accessDenied": "Access Denied", "accessDenied": "Access Denied",
"accessDeniedDesc": "You do not have the required permissions to access or perform actions on this resource. Please contact your administrator." "accessDeniedDesc": "You do not have the required permissions to access or perform actions on this resource. Please contact your administrator."
},
"currency": {
"converterTitle": "Currency Converter",
"converterSubtitle": "Quickly convert popular currencies",
"from": "From",
"to": "To",
"exchangeRate": "Exchange Rate",
"customRate": "Edit Rate",
"apply": "Apply",
"swap": "Swap",
"liveRates": "Market Rates",
"custom": "Custom",
"vnd": "Vietnamese Dong",
"usd": "US Dollar",
"eur": "Euro",
"jpy": "Japanese Yen",
"krw": "Korean Won",
"cny": "Chinese Yuan",
"gbp": "British Pound",
"thb": "Thai Baht",
"sgd": "Singapore Dollar",
"aud": "Australian Dollar",
"cad": "Canadian Dollar"
} }
} }
...@@ -2289,5 +2289,28 @@ ...@@ -2289,5 +2289,28 @@
}, },
"accessDenied": "Không có quyền truy cập", "accessDenied": "Không có quyền truy cập",
"accessDeniedDesc": "Bạn không có quyền hạn để truy cập hoặc thực hiện thao tác này. Vui lòng liên hệ Quản trị viên để được cấp quyền." "accessDeniedDesc": "Bạn không có quyền hạn để truy cập hoặc thực hiện thao tác này. Vui lòng liên hệ Quản trị viên để được cấp quyền."
},
"currency": {
"converterTitle": "Chuyển đổi ngoại tệ",
"converterSubtitle": "Quy đổi nhanh các loại tiền tệ",
"from": "Từ",
"to": "Sang",
"exchangeRate": "Tỷ giá",
"customRate": "Sửa tỷ giá",
"apply": "Áp dụng",
"swap": "Đảo chiều",
"liveRates": "Tỷ giá thị trường",
"custom": "Tùy biến",
"vnd": "Việt Nam Đồng",
"usd": "Đô la Mỹ",
"eur": "Euro",
"jpy": "Yên Nhật",
"krw": "Won Hàn Quốc",
"cny": "Nhân dân tệ",
"gbp": "Bảng Anh",
"thb": "Baht Thái",
"sgd": "Đô la Singapore",
"aud": "Đô la Úc",
"cad": "Đô la Canada"
} }
} }
...@@ -633,6 +633,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -633,6 +633,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
isOpen={isCalculatorOpen} isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)} onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")} initialAmount={watch("amount")}
defaultCurrency={walletsQuery.data?.data?.find((w) => w.id === watch("walletId"))?.currency || "VND"}
onApply={(calculatedAmount) => { onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, { setValue("amount", calculatedAmount, {
shouldValidate: true, shouldValidate: true,
......
...@@ -296,6 +296,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -296,6 +296,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
isOpen={isCalculatorOpen} isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)} onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("balance")} initialAmount={watch("balance")}
defaultCurrency={currentCurrency}
onApply={(calculatedAmount) => { onApply={(calculatedAmount) => {
setValue("balance", calculatedAmount, { setValue("balance", calculatedAmount, {
shouldValidate: true, shouldValidate: true,
......
import {
CurrencyCode,
CurrencyConversionParams,
CurrencyConversionResult,
CurrencyItem,
ExchangeRateMap,
} from "@/types/currency";
export const POPULAR_CURRENCIES: CurrencyItem[] = [
{ code: "VND", symbol: "₫", nameKey: "currency.vnd", decimals: 0 },
{ code: "USD", symbol: "$", nameKey: "currency.usd", decimals: 2 },
{ code: "EUR", symbol: "€", nameKey: "currency.eur", decimals: 2 },
{ code: "JPY", symbol: "¥", nameKey: "currency.jpy", decimals: 0 },
{ code: "KRW", symbol: "₩", nameKey: "currency.krw", decimals: 0 },
{ code: "CNY", symbol: "¥", nameKey: "currency.cny", decimals: 2 },
{ code: "GBP", symbol: "£", nameKey: "currency.gbp", decimals: 2 },
{ code: "THB", symbol: "฿", nameKey: "currency.thb", decimals: 2 },
{ code: "SGD", symbol: "S$", nameKey: "currency.sgd", decimals: 2 },
{ code: "AUD", symbol: "A$", nameKey: "currency.aud", decimals: 2 },
{ code: "CAD", symbol: "C$", nameKey: "currency.cad", decimals: 2 },
];
/**
* Standard default exchange rates relative to VND (1 foreign unit = X VND).
* Regularly updated as baseline fallback for offline / quick calculations.
*/
export const DEFAULT_VND_RATES: ExchangeRateMap = {
VND: 1,
USD: 25450,
EUR: 27600,
JPY: 165,
KRW: 19,
CNY: 3550,
GBP: 32800,
THB: 740,
SGD: 19400,
AUD: 16800,
CAD: 18500,
};
const CACHE_KEY = "finwise.currency_rates";
const CACHE_TIME_KEY = "finwise.currency_rates_timestamp";
const CACHE_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
/**
* Calculate the exchange rate from one currency to another (1 unit of from = X units of to).
*/
export function getExchangeRate(
from: string,
to: string,
rates: ExchangeRateMap = DEFAULT_VND_RATES,
customRate?: number
): number {
if (customRate && customRate > 0) {
return customRate;
}
const fromCode = from.toUpperCase();
const toCode = to.toUpperCase();
if (fromCode === toCode) {
return 1;
}
const fromRateInVnd = rates[fromCode] ?? DEFAULT_VND_RATES[fromCode] ?? 1;
const toRateInVnd = rates[toCode] ?? DEFAULT_VND_RATES[toCode] ?? 1;
if (toRateInVnd === 0) return 1;
return fromRateInVnd / toRateInVnd;
}
/**
* Convert an amount between two currencies.
*/
export function convertCurrency({
amount,
from,
to,
customRate,
}: CurrencyConversionParams): CurrencyConversionResult {
const rate = getExchangeRate(from, to, getCachedRates(), customRate);
const rawConverted = Math.max(0, amount * rate);
const targetItem = POPULAR_CURRENCIES.find((c) => c.code === to.toUpperCase());
const decimals = targetItem ? targetItem.decimals : to.toUpperCase() === "VND" ? 0 : 2;
const toAmount =
decimals === 0
? Math.round(rawConverted)
: Math.round(rawConverted * Math.pow(10, decimals)) / Math.pow(10, decimals);
return {
fromAmount: amount,
toAmount,
rate,
from: from.toUpperCase(),
to: to.toUpperCase(),
};
}
/**
* Get cached rates or fallback to default rates.
*/
export function getCachedRates(): ExchangeRateMap {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed = JSON.parse(cached);
if (typeof parsed === "object" && parsed !== null) {
return { ...DEFAULT_VND_RATES, ...parsed };
}
}
} catch {
// Ignore localStorage parse errors
}
return DEFAULT_VND_RATES;
}
/**
* Fetch latest exchange rates from open exchange API with cache and fallback.
*/
export async function fetchLatestRates(): Promise<ExchangeRateMap> {
try {
const lastTimestamp = Number(localStorage.getItem(CACHE_TIME_KEY) || 0);
const now = Date.now();
if (now - lastTimestamp < CACHE_TTL_MS) {
const cached = getCachedRates();
if (cached && Object.keys(cached).length > 1) {
return cached;
}
}
// Try fetching USD-based rates from free open API
const response = await fetch("https://open.er-api.com/v6/latest/USD");
if (!response.ok) {
return getCachedRates();
}
const data = await response.json();
const usdRates = data?.rates;
if (usdRates && typeof usdRates === "object" && usdRates.VND) {
const usdVnd = Number(usdRates.VND) || DEFAULT_VND_RATES.USD;
const newVndRates: ExchangeRateMap = { VND: 1 };
for (const [code, rateAgainstUsd] of Object.entries(usdRates)) {
const rateNum = Number(rateAgainstUsd);
if (rateNum > 0) {
// 1 USD = rateNum [CODE] => 1 [CODE] = (usdVnd / rateNum) VND
newVndRates[code] = Math.round((usdVnd / rateNum) * 100) / 100;
}
}
const merged = { ...DEFAULT_VND_RATES, ...newVndRates };
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {
// Storage might be full
}
return merged;
}
} catch {
// Graceful fallback to default offline rates
}
return getCachedRates();
}
export type CurrencyCode =
| "VND"
| "USD"
| "EUR"
| "JPY"
| "KRW"
| "CNY"
| "GBP"
| "THB"
| "SGD"
| "AUD"
| "CAD";
export interface CurrencyItem {
code: CurrencyCode;
symbol: string;
nameKey: string;
decimals: number;
}
export interface ExchangeRateMap {
[code: string]: number;
}
export interface CurrencyConversionParams {
amount: number;
from: CurrencyCode | string;
to: CurrencyCode | string;
customRate?: number;
}
export interface CurrencyConversionResult {
fromAmount: number;
toAmount: number;
rate: number;
from: string;
to: 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