Commit c4c60432 authored by ThinhNC's avatar ThinhNC

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

parent d77a3367
......@@ -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.
- 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ý 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ý 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.
......
......@@ -14,10 +14,10 @@
<meta name="theme-color" content="#F3F0FA" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<title>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="shortcut icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo.png" />
<title>FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính</title>
<link rel="icon" type="image/png" href="/src/static/logo.png" />
<link rel="shortcut icon" type="image/png" href="/src/static/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.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">
......
......@@ -51,25 +51,98 @@ export const DocumentTitle: React.FC = () => {
const { t } = useI18n();
useEffect(() => {
// Dynamically set / update favicon to guarantee browser tab displays FinWise logo
const iconRels = ["icon", "shortcut icon", "apple-touch-icon"];
iconRels.forEach((rel) => {
let link: HTMLLinkElement | null = document.querySelector(`link[rel='${rel}']`);
if (!link) {
link = document.createElement("link");
link.rel = rel;
document.head.appendChild(link);
// 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"];
iconRels.forEach((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
}
if (rel !== "apple-touch-icon") {
link.type = "image/png";
};
// 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) {
link = document.createElement("link");
link.rel = rel;
document.head.appendChild(link);
}
if (rel !== "apple-touch-icon") {
link.type = "image/png";
}
link.href = "${fullLogoUrl}";
});
} catch (e) {}
`,
},
"*"
);
} catch {
// Ignore
}
}
link.href = finwiseLogo;
});
}
}, []);
useEffect(() => {
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]);
return null;
......
This diff is collapsed.
......@@ -354,4 +354,23 @@ export const CalculatorIcon: React.FC<IconProps> = ({ size = 24, ...props }) =>
</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 @@
},
"accessDenied": "Access Denied",
"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 @@
},
"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."
},
"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> = ({
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
defaultCurrency={walletsQuery.data?.data?.find((w) => w.id === watch("walletId"))?.currency || "VND"}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
......
......@@ -296,6 +296,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("balance")}
defaultCurrency={currentCurrency}
onApply={(calculatedAmount) => {
setValue("balance", calculatedAmount, {
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