Commit dd816551 authored by ThinhNC's avatar ThinhNC

fix(fe): resolve full-project audit findings, enforce type safety, and harden a11y & resilience

parent 9ccf6885
......@@ -8,7 +8,7 @@
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#F3F0FA" />
......
......@@ -18,7 +18,8 @@
"login": "zmp login",
"start": "zmp start",
"deploy": "zmp deploy",
"build": "vite build"
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@hookform/resolvers": "^5.7.1",
......@@ -44,6 +45,7 @@
"postcss-preset-env": "^6.7.0",
"sass": "^1.76.0",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.13",
"zmp-vite-plugin": "latest"
}
......
......@@ -72,6 +72,9 @@ importers:
tailwindcss:
specifier: ^3.4.3
version: 3.4.19
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.2.13
version: 5.4.21(sass@1.102.0)
......@@ -1783,6 +1786,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
uniq@1.0.1:
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
......@@ -3480,6 +3488,8 @@ snapshots:
tslib@2.8.1: {}
typescript@5.9.3: {}
uniq@1.0.1: {}
universalify@2.0.1: {}
......
import React, { Component, ErrorInfo, ReactNode } from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[ErrorBoundary caught an error]:", error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
};
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen w-full bg-clay-bg flex items-center justify-center p-4">
<Card className="max-w-md w-full p-6 text-center flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-clay bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-pressed text-2xl font-bold font-baloo">
!
</div>
<div>
<h2 className="clay-title-h2">Đã xảy ra lỗi</h2>
<p className="clay-caption mt-1.5 text-clay-text-muted">
{this.state.error?.message || "Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại hoặc quay về trang chủ."}
</p>
</div>
<div className="flex gap-3 mt-2 w-full">
<Button
variant="secondary"
fullWidth
onClick={() => window.location.reload()}
>
Tải lại
</Button>
<Button
variant="primary"
fullWidth
onClick={this.handleReset}
>
Về trang chủ
</Button>
</div>
</Card>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
import { useEffect, useState } from "react";
import { Text } from "zmp-ui";
import { useI18n } from "@/i18n";
function Clock() {
const { formatDate } = useI18n();
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = formatDate(now, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
setTime(formattedTime);
};
updateClock();
const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId);
}, [formatDate]);
return <Text className="font-mono">{time}</Text>;
}
export default Clock;
......@@ -20,8 +20,9 @@ import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitche
import { NotificationShortcut } from "@/components/shared/NotificationShortcut";
import { NotificationRealtimeListener } from "@/components/shared/NotificationRealtimeListener";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide";
import LoginPage from "@/pages/auth/login";
import RegisterPage from "@/pages/auth/register";
import ForgotPasswordPage from "@/pages/auth/forgot-password";
......@@ -38,25 +39,36 @@ import SavingGoalsPage from "@/pages/saving-goals/index";
import SavingGoalDetailPage from "@/pages/saving-goals/detail";
import ReportsPage from "@/pages/reports/index";
import NotificationsPage from "@/pages/notifications/index";
import AIAssistantPage from "@/pages/ai-assistant/index";
import ForecastPage from "@/pages/forecast/index";
import SimulationsPage from "@/pages/simulations/index";
import AnomaliesPage from "@/pages/anomalies/index";
import QueryPage from "@/pages/query/index";
import RecurringTransactionsPage from "@/pages/recurring-transactions/index";
import RolesPage from "@/pages/admin/roles/index";
import AdminDashboardPage from "@/pages/admin/index";
import AdminUsersPage from "@/pages/admin/users/index";
import AdminUserDetailPage from "@/pages/admin/users/detail";
import AdminAuditLogsPage from "@/pages/admin/audit-logs/index";
import AdminSettingsPage from "@/pages/admin/settings/index";
import AdminNotificationsPage from "@/pages/admin/notifications/index";
import AdminAiPage from "@/pages/admin/ai/index";
import IntegrationsPage from "@/pages/integrations/index";
// Lazy-loaded routes for code splitting
const AIAssistantPage = React.lazy(() => import("@/pages/ai-assistant/index"));
const ForecastPage = React.lazy(() => import("@/pages/forecast/index"));
const SimulationsPage = React.lazy(() => import("@/pages/simulations/index"));
const AnomaliesPage = React.lazy(() => import("@/pages/anomalies/index"));
const QueryPage = React.lazy(() => import("@/pages/query/index"));
const StyleGuidePage = React.lazy(() => import("@/pages/style-guide"));
const RolesPage = React.lazy(() => import("@/pages/admin/roles/index"));
const AdminDashboardPage = React.lazy(() => import("@/pages/admin/index"));
const AdminUsersPage = React.lazy(() => import("@/pages/admin/users/index"));
const AdminUserDetailPage = React.lazy(() => import("@/pages/admin/users/detail"));
const AdminAuditLogsPage = React.lazy(() => import("@/pages/admin/audit-logs/index"));
const AdminSettingsPage = React.lazy(() => import("@/pages/admin/settings/index"));
const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notifications/index"));
const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index"));
const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate";
import { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants";
const PageLoadingFallback: React.FC = () => (
<div className="min-h-screen bg-clay-bg flex items-center justify-center p-4">
<div className="w-10 h-10 rounded-full border-4 border-clay-primary/25 border-t-clay-primary animate-spin" />
</div>
);
const SubscriptionsRedirect: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
......@@ -99,6 +111,7 @@ export const Layout = () => {
}, [theme]);
return (
<ErrorBoundary>
<I18nProvider>
<App>
<QueryClientProvider client={queryClient}>
......@@ -112,6 +125,7 @@ export const Layout = () => {
</div>
<AuthInitializer>
<NotificationRealtimeListener />
<React.Suspense fallback={<PageLoadingFallback />}>
<AnimationRoutes>
{/* Public Auth Routes */}
<Route path="/login" element={<LoginPage />}></Route>
......@@ -152,14 +166,17 @@ export const Layout = () => {
<Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
{/* 404 Catch-All Route */}
<Route path="*" element={<NotFoundPage />}></Route>
</AnimationRoutes>
</React.Suspense>
</AuthInitializer>
</ZMPRouter>
</SnackbarProvider>
</QueryClientProvider>
</App>
</I18nProvider>
</ErrorBoundary>
);
};
export default Layout;
import React, { useEffect, useState } from "react";
import { CalculatorIcon, CheckIcon, CloseIcon, ExchangeIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/Button";
import { useI18n } from "@/i18n";
import {
POPULAR_CURRENCIES,
convertCurrency,
getExchangeRate,
getCachedRates,
fetchLatestRates,
fetchLiveExchangeRate,
} from "@/services/currency.service";
import { ExchangeRateMap } from "@/types/currency";
export interface CalculatorButtonProps {
onClick: () => void;
disabled?: boolean;
className?: string;
id?: string;
size?: number;
title?: string;
}
export const CalculatorButton: React.FC<CalculatorButtonProps> = ({
onClick,
disabled = false,
className = "",
id,
size = 22,
title,
}) => {
const { t } = useI18n();
const displayTitle = title || t("transaction.calculator") || "Máy tính";
return (
<button
id={id}
type="button"
onClick={onClick}
disabled={disabled}
title={displayTitle}
aria-label={displayTitle}
className={`flex h-[46px] w-[46px] shrink-0 items-center justify-center rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/60 shadow-clay-raised transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:scale-105 active:shadow-clay-pressed active:scale-95 disabled:opacity-50 disabled:pointer-events-none ${className}`}
>
<CalculatorIcon size={size} />
</button>
);
};
export interface CalculatorModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (amount: string) => void;
initialAmount?: string;
title?: string;
defaultCurrency?: string;
}
type Operator = "+" | "-" | "*" | "/";
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const defaultGroup = locale.startsWith("vi") ? "." : ",";
const defaultDecimal = locale.startsWith("vi") ? "," : ".";
try {
const formatter = new Intl.NumberFormat(locale);
if (typeof formatter.formatToParts === "function") {
const parts = formatter.formatToParts(1234.5);
const group = parts.find((p) => p.type === "group")?.value;
const decimal = parts.find((p) => p.type === "decimal")?.value;
if (group && decimal) {
return { group, decimal };
}
}
const nonDigits = formatter.format(1234.5).match(/[^\d]/g);
const g = nonDigits?.[0];
const d = nonDigits?.[1];
if (g && d) {
return { group: g, decimal: d };
}
} catch {
// Fallback if Intl is unavailable or fails
}
return {
group: defaultGroup,
decimal: defaultDecimal,
};
}
function formatDisplayValue(raw: string, locale: string): string {
if (!raw) return "0";
const [intPart, decPart] = raw.split(".");
const { group, decimal } = getNumberSeparators(locale);
const formattedInt = (intPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
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 {
switch (op) {
case "+":
return "+";
case "-":
return "−";
case "*":
return "×";
case "/":
return "÷";
}
}
export const CalculatorModal: React.FC<CalculatorModalProps> = ({
isOpen,
onClose,
onApply,
initialAmount = "",
title,
defaultCurrency = "VND",
}) => {
const { t, intlLocale } = useI18n();
const [display, setDisplay] = useState<string>("0");
const [expression, setExpression] = useState<string>("");
const [prevValue, setPrevValue] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [waitingForOperand, setWaitingForOperand] = 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>("");
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
useEffect(() => {
if (isOpen) {
const sanitized = initialAmount ? String(parseFloat(initialAmount) || 0) : "0";
setDisplay(sanitized === "0" ? "0" : sanitized);
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
// Reset converter state on open
const def = (defaultCurrency || "VND").toUpperCase();
setToCurrency(def);
setFromCurrency(def === "USD" ? "VND" : "USD");
setCustomRate(undefined);
setIsEditingRate(false);
setLiveRateInfo(null);
setIsLoadingRate(false);
// Refresh rates in background
fetchLatestRates().then((latest) => {
if (latest) setRates(latest);
});
}
}, [isOpen, initialAmount, defaultCurrency]);
if (!isOpen) return null;
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 conversionResult = convertCurrency({
amount: currentNum,
from: fromCurrency,
to: toCurrency,
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 convertedStr = String(conversionResult.toAmount);
setDisplay(convertedStr);
setExpression(`${formatDisplayValue(display, intlLocale)} ${fromCurrency}${toCurrency}`);
setIsCalculated(true);
};
const handleSwapCurrencies = () => {
const nextFrom = toCurrency;
const nextTo = fromCurrency;
setFromCurrency(nextFrom);
setToCurrency(nextTo);
setCustomRate(undefined);
setIsEditingRate(false);
fetchRate(nextFrom, nextTo);
};
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 => {
let res = 0;
switch (op) {
case "+":
res = prev + current;
break;
case "-":
res = prev - current;
break;
case "*":
res = prev * current;
break;
case "/":
res = current === 0 ? 0 : prev / current;
break;
}
// Round to 2 decimal places and ensure non-negative
const rounded = Math.round(res * 100) / 100;
return Math.max(0, rounded);
};
const handleDigit = (digit: string) => {
if (isCalculated) {
setIsCalculated(false);
if (digit === "0") {
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 10 * 100) / 100
: display + "0";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
// Digit 1-9: starts fresh number
setDisplay(digit);
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay(digit);
setWaitingForOperand(false);
} else {
if (display === "0") {
setDisplay(digit);
} else if (display.length < 14) {
setDisplay(display + digit);
}
}
};
const handleTripleZero = () => {
if (isCalculated) {
setIsCalculated(false);
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 1000 * 100) / 100
: display + "000";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
if (waitingForOperand) {
setDisplay("0");
setWaitingForOperand(false);
} else {
if (display !== "0" && display.length <= 11) {
setDisplay(display + "000");
}
}
};
const handleDecimal = () => {
if (isCalculated) {
setIsCalculated(false);
setDisplay("0.");
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay("0.");
setWaitingForOperand(false);
} else if (!display.includes(".")) {
setDisplay(display + ".");
}
};
const handleOperator = (nextOp: Operator) => {
setIsCalculated(false);
const currentNum = parseFloat(display) || 0;
if (prevValue !== null && operator && !waitingForOperand) {
const computed = calculateResult(prevValue, currentNum, operator);
setPrevValue(computed);
setDisplay(String(computed));
setExpression(`${formatDisplayValue(String(computed), intlLocale)} ${opSymbol(nextOp)}`);
} else {
setPrevValue(currentNum);
setExpression(`${formatDisplayValue(display, intlLocale)} ${opSymbol(nextOp)}`);
}
setOperator(nextOp);
setWaitingForOperand(true);
};
const handleEquals = () => {
if (prevValue === null || !operator) return;
const currentNum = parseFloat(display) || 0;
const computed = calculateResult(prevValue, currentNum, operator);
setExpression(
`${formatDisplayValue(String(prevValue), intlLocale)} ${opSymbol(operator)} ${formatDisplayValue(display, intlLocale)} =`
);
setDisplay(String(computed));
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(true);
};
const handleClear = () => {
setDisplay("0");
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
};
const handleBackspace = () => {
if (isCalculated) {
setIsCalculated(false);
setExpression("");
}
if (waitingForOperand) return;
if (display.length > 1) {
setDisplay(display.slice(0, -1));
} else {
setDisplay("0");
}
};
const handleDone = () => {
let finalNum = parseFloat(display) || 0;
// If there's an uncompleted operation, calculate it
if (prevValue !== null && operator && !waitingForOperand) {
finalNum = calculateResult(prevValue, finalNum, operator);
}
finalNum = Math.max(0, finalNum);
const resultStr = Number.isInteger(finalNum)
? String(finalNum)
: String(Number(finalNum.toFixed(2)));
onApply(resultStr);
onClose();
};
return (
<div className="fixed inset-0 z-[1050] flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
{/* Backdrop */}
<div
className="absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
onClick={onClose}
/>
{/* Calculator Container */}
<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 */}
<div className="flex items-center justify-between pb-1 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2">
<CalculatorIcon size={22} className="text-clay-primary" />
<h3 className="clay-title-h3 text-base">
{title || t("transaction.calculatorTitle") || "Máy tính giao dịch"}
</h3>
</div>
<div className="flex items-center gap-1.5">
{/* Currency Converter Toggle Button (Next to X) */}
<button
id="btn-toggle-converter"
type="button"
onClick={handleToggleConverter}
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} className={isLoadingRate ? "animate-spin" : ""} />
</button>
{/* Close Button */}
<button
type="button"
onClick={onClose}
aria-label={t("accessibility.closeModal") || "Đóng"}
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} />
</button>
</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) => {
const newFrom = e.target.value;
setFromCurrency(newFrom);
setCustomRate(undefined);
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"
>
{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) => {
const newTo = e.target.value;
setToCurrency(newTo);
setCustomRate(undefined);
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"
>
{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>
{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">
<input
type="number"
step="any"
min="0"
value={customRateInput}
onChange={(e) => setCustomRateInput(e.target.value)}
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"
/>
<button
type="button"
onClick={handleSaveCustomRate}
className="text-[10px] text-clay-primary font-bold hover:underline"
>
{t("common.save") || "Lưu"}
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-baloo font-bold text-clay-text text-xs flex items-center gap-1">
1 {fromCurrency} = {formatRateDisplay(activeRate, intlLocale)} {toCurrency}
{customRate !== undefined ? (
<span className="ml-1 text-[10px] text-clay-expense font-normal">
({t("currency.custom") || "Tùy biến"})
</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>
{!isEditingRate && !isLoadingRate && (
<button
type="button"
onClick={() => {
setCustomRateInput(String(activeRate));
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 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 */}
<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}
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"}
</button>
</div>
</div>
)}
{/* 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="flex items-center justify-between text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4">
<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 className="text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate">
{formatDisplayValue(display, intlLocale)}
</div>
</div>
{/* Keypad Grid */}
<div className="grid grid-cols-4 gap-2">
{/* Row 1 */}
<button
type="button"
onClick={handleClear}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
C
</button>
<button
type="button"
onClick={handleBackspace}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
<button
type="button"
onClick={handleTripleZero}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/50 font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
000
</button>
<button
type="button"
onClick={() => handleOperator("/")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
÷
</button>
{/* Row 2 */}
<button
type="button"
onClick={() => handleDigit("7")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
7
</button>
<button
type="button"
onClick={() => handleDigit("8")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
8
</button>
<button
type="button"
onClick={() => handleDigit("9")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
9
</button>
<button
type="button"
onClick={() => handleOperator("*")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
×
</button>
{/* Row 3 */}
<button
type="button"
onClick={() => handleDigit("4")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
4
</button>
<button
type="button"
onClick={() => handleDigit("5")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
5
</button>
<button
type="button"
onClick={() => handleDigit("6")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
6
</button>
<button
type="button"
onClick={() => handleOperator("-")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
{/* Row 4 */}
<button
type="button"
onClick={() => handleDigit("1")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
1
</button>
<button
type="button"
onClick={() => handleDigit("2")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
2
</button>
<button
type="button"
onClick={() => handleDigit("3")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
3
</button>
<button
type="button"
onClick={() => handleOperator("+")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
+
</button>
{/* Row 5 */}
<button
type="button"
onClick={() => handleDigit("0")}
className="col-span-2 h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
0
</button>
<button
type="button"
onClick={handleDecimal}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-lg shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
.
</button>
<button
type="button"
onClick={handleEquals}
className="h-11 rounded-clay-sm bg-clay-primary text-clay-on-primary border border-clay-primary-dark/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
=
</button>
</div>
{/* Done Action Button */}
<Button
id="btn-calc-done"
type="button"
variant="primary"
fullWidth
onClick={handleDone}
className="mt-1 py-3 text-base font-baloo font-bold shadow-clay-raised"
>
<span className="flex items-center justify-center gap-2">
<CheckIcon size={18} />
<span>{t("transaction.calcDone") || "Xong"}</span>
</span>
</Button>
</div>
</div>
);
};
export default CalculatorModal;
......@@ -5,6 +5,7 @@ interface CategoryArtworkProps {
color?: string | null;
size?: "sm" | "md" | "lg";
archived?: boolean;
className?: string;
}
const dimensions = {
......@@ -67,10 +68,11 @@ export const CategoryArtwork: React.FC<CategoryArtworkProps> = ({
color = "#8B7CF6",
size = "md",
archived = false,
className = "",
}) => (
<div
aria-hidden="true"
className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""}`}
className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""} ${className}`}
style={{ backgroundColor: color || "#8B7CF6" }}
>
<svg
......
......@@ -31,6 +31,7 @@ export const WalletCard: React.FC<WalletCardProps> = ({
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
......
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
variant?: "primary" | "secondary" | "ghost" | "danger";
shape?: "clay" | "pill";
fullWidth?: boolean;
}
......@@ -20,6 +20,7 @@ export const Button: React.FC<ButtonProps> = ({
primary: "bg-clay-primary text-clay-on-primary border-2 border-clay-primary-dark/30 hover:bg-clay-primary/95 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
secondary: "bg-clay-surface text-clay-text border-2 border-clay-text/10 hover:bg-clay-surface/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]",
danger: "bg-clay-expense text-white border-2 border-clay-expense/30 hover:bg-clay-expense/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
};
// Border radius shapes
......
import React, { useEffect, useState } from "react";
import { CalculatorIcon, CheckIcon, CloseIcon, ExchangeIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/Button";
import { useI18n } from "@/i18n";
import {
POPULAR_CURRENCIES,
convertCurrency,
getExchangeRate,
getCachedRates,
fetchLatestRates,
fetchLiveExchangeRate,
} from "@/services/currency.service";
import { ExchangeRateMap } from "@/types/currency";
export interface CalculatorButtonProps {
onClick: () => void;
disabled?: boolean;
className?: string;
id?: string;
size?: number;
title?: string;
}
export const CalculatorButton: React.FC<CalculatorButtonProps> = ({
onClick,
disabled = false,
className = "",
id,
size = 22,
title,
}) => {
const { t } = useI18n();
const displayTitle = title || t("transaction.calculator") || "Máy tính";
return (
<button
id={id}
type="button"
onClick={onClick}
disabled={disabled}
title={displayTitle}
aria-label={displayTitle}
className={`flex h-[46px] w-[46px] shrink-0 items-center justify-center rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/60 shadow-clay-raised transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:scale-105 active:shadow-clay-pressed active:scale-95 disabled:opacity-50 disabled:pointer-events-none ${className}`}
>
<CalculatorIcon size={size} />
</button>
);
};
export interface CalculatorModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (amount: string) => void;
initialAmount?: string;
title?: string;
defaultCurrency?: string;
}
type Operator = "+" | "-" | "*" | "/";
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const defaultGroup = locale.startsWith("vi") ? "." : ",";
const defaultDecimal = locale.startsWith("vi") ? "," : ".";
try {
const formatter = new Intl.NumberFormat(locale);
if (typeof formatter.formatToParts === "function") {
const parts = formatter.formatToParts(1234.5);
const group = parts.find((p) => p.type === "group")?.value;
const decimal = parts.find((p) => p.type === "decimal")?.value;
if (group && decimal) {
return { group, decimal };
}
}
const nonDigits = formatter.format(1234.5).match(/[^\d]/g);
const g = nonDigits?.[0];
const d = nonDigits?.[1];
if (g && d) {
return { group: g, decimal: d };
}
} catch {
// Fallback if Intl is unavailable or fails
}
return {
group: defaultGroup,
decimal: defaultDecimal,
};
}
function formatDisplayValue(raw: string, locale: string): string {
if (!raw) return "0";
const [intPart, decPart] = raw.split(".");
const { group, decimal } = getNumberSeparators(locale);
const formattedInt = (intPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
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 {
switch (op) {
case "+":
return "+";
case "-":
return "−";
case "*":
return "×";
case "/":
return "÷";
}
}
export const CalculatorModal: React.FC<CalculatorModalProps> = ({
isOpen,
onClose,
onApply,
initialAmount = "",
title,
defaultCurrency = "VND",
}) => {
const { t, intlLocale } = useI18n();
const [display, setDisplay] = useState<string>("0");
const [expression, setExpression] = useState<string>("");
const [prevValue, setPrevValue] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [waitingForOperand, setWaitingForOperand] = 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>("");
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
useEffect(() => {
if (isOpen) {
const sanitized = initialAmount ? String(parseFloat(initialAmount) || 0) : "0";
setDisplay(sanitized === "0" ? "0" : sanitized);
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
// Reset converter state on open
const def = (defaultCurrency || "VND").toUpperCase();
setToCurrency(def);
setFromCurrency(def === "USD" ? "VND" : "USD");
setCustomRate(undefined);
setIsEditingRate(false);
setLiveRateInfo(null);
setIsLoadingRate(false);
// Refresh rates in background
fetchLatestRates().then((latest) => {
if (latest) setRates(latest);
});
}
}, [isOpen, initialAmount, defaultCurrency]);
if (!isOpen) return null;
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 conversionResult = convertCurrency({
amount: currentNum,
from: fromCurrency,
to: toCurrency,
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 convertedStr = String(conversionResult.toAmount);
setDisplay(convertedStr);
setExpression(`${formatDisplayValue(display, intlLocale)} ${fromCurrency}${toCurrency}`);
setIsCalculated(true);
};
const handleSwapCurrencies = () => {
const nextFrom = toCurrency;
const nextTo = fromCurrency;
setFromCurrency(nextFrom);
setToCurrency(nextTo);
setCustomRate(undefined);
setIsEditingRate(false);
fetchRate(nextFrom, nextTo);
};
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 => {
let res = 0;
switch (op) {
case "+":
res = prev + current;
break;
case "-":
res = prev - current;
break;
case "*":
res = prev * current;
break;
case "/":
res = current === 0 ? 0 : prev / current;
break;
}
// Round to 2 decimal places and ensure non-negative
const rounded = Math.round(res * 100) / 100;
return Math.max(0, rounded);
};
const handleDigit = (digit: string) => {
if (isCalculated) {
setIsCalculated(false);
if (digit === "0") {
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 10 * 100) / 100
: display + "0";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
// Digit 1-9: starts fresh number
setDisplay(digit);
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay(digit);
setWaitingForOperand(false);
} else {
if (display === "0") {
setDisplay(digit);
} else if (display.length < 14) {
setDisplay(display + digit);
}
}
};
const handleTripleZero = () => {
if (isCalculated) {
setIsCalculated(false);
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 1000 * 100) / 100
: display + "000";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
if (waitingForOperand) {
setDisplay("0");
setWaitingForOperand(false);
} else {
if (display !== "0" && display.length <= 11) {
setDisplay(display + "000");
}
}
};
const handleDecimal = () => {
if (isCalculated) {
setIsCalculated(false);
setDisplay("0.");
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay("0.");
setWaitingForOperand(false);
} else if (!display.includes(".")) {
setDisplay(display + ".");
}
};
const handleOperator = (nextOp: Operator) => {
setIsCalculated(false);
const currentNum = parseFloat(display) || 0;
if (prevValue !== null && operator && !waitingForOperand) {
const computed = calculateResult(prevValue, currentNum, operator);
setPrevValue(computed);
setDisplay(String(computed));
setExpression(`${formatDisplayValue(String(computed), intlLocale)} ${opSymbol(nextOp)}`);
} else {
setPrevValue(currentNum);
setExpression(`${formatDisplayValue(display, intlLocale)} ${opSymbol(nextOp)}`);
}
setOperator(nextOp);
setWaitingForOperand(true);
};
const handleEquals = () => {
if (prevValue === null || !operator) return;
const currentNum = parseFloat(display) || 0;
const computed = calculateResult(prevValue, currentNum, operator);
setExpression(
`${formatDisplayValue(String(prevValue), intlLocale)} ${opSymbol(operator)} ${formatDisplayValue(display, intlLocale)} =`
);
setDisplay(String(computed));
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(true);
};
const handleClear = () => {
setDisplay("0");
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
};
const handleBackspace = () => {
if (isCalculated) {
setIsCalculated(false);
setExpression("");
}
if (waitingForOperand) return;
if (display.length > 1) {
setDisplay(display.slice(0, -1));
} else {
setDisplay("0");
}
};
const handleDone = () => {
let finalNum = parseFloat(display) || 0;
// If there's an uncompleted operation, calculate it
if (prevValue !== null && operator && !waitingForOperand) {
finalNum = calculateResult(prevValue, finalNum, operator);
}
finalNum = Math.max(0, finalNum);
const resultStr = Number.isInteger(finalNum)
? String(finalNum)
: String(Number(finalNum.toFixed(2)));
onApply(resultStr);
onClose();
};
return (
<div className="fixed inset-0 z-[1050] flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
{/* Backdrop */}
<div
className="absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
onClick={onClose}
/>
{/* Calculator Container */}
<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 */}
<div className="flex items-center justify-between pb-1 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2">
<CalculatorIcon size={22} className="text-clay-primary" />
<h3 className="clay-title-h3 text-base">
{title || t("transaction.calculatorTitle") || "Máy tính giao dịch"}
</h3>
</div>
<div className="flex items-center gap-1.5">
{/* Currency Converter Toggle Button (Next to X) */}
<button
id="btn-toggle-converter"
type="button"
onClick={handleToggleConverter}
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} className={isLoadingRate ? "animate-spin" : ""} />
</button>
{/* Close Button */}
<button
type="button"
onClick={onClose}
aria-label={t("accessibility.closeModal") || "Đóng"}
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} />
</button>
</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) => {
const newFrom = e.target.value;
setFromCurrency(newFrom);
setCustomRate(undefined);
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"
>
{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) => {
const newTo = e.target.value;
setToCurrency(newTo);
setCustomRate(undefined);
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"
>
{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>
{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">
<input
type="number"
step="any"
min="0"
value={customRateInput}
onChange={(e) => setCustomRateInput(e.target.value)}
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"
/>
<button
type="button"
onClick={handleSaveCustomRate}
className="text-[10px] text-clay-primary font-bold hover:underline"
>
{t("common.save") || "Lưu"}
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-baloo font-bold text-clay-text text-xs flex items-center gap-1">
1 {fromCurrency} = {formatRateDisplay(activeRate, intlLocale)} {toCurrency}
{customRate !== undefined ? (
<span className="ml-1 text-[10px] text-clay-expense font-normal">
({t("currency.custom") || "Tùy biến"})
</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>
{!isEditingRate && !isLoadingRate && (
<button
type="button"
onClick={() => {
setCustomRateInput(String(activeRate));
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 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 */}
<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}
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"}
</button>
</div>
</div>
)}
{/* 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="flex items-center justify-between text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4">
<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 className="text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate">
{formatDisplayValue(display, intlLocale)}
</div>
</div>
{/* Keypad Grid */}
<div className="grid grid-cols-4 gap-2">
{/* Row 1 */}
<button
type="button"
onClick={handleClear}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
C
</button>
<button
type="button"
onClick={handleBackspace}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
<button
type="button"
onClick={handleTripleZero}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/50 font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
000
</button>
<button
type="button"
onClick={() => handleOperator("/")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
÷
</button>
{/* Row 2 */}
<button
type="button"
onClick={() => handleDigit("7")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
7
</button>
<button
type="button"
onClick={() => handleDigit("8")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
8
</button>
<button
type="button"
onClick={() => handleDigit("9")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
9
</button>
<button
type="button"
onClick={() => handleOperator("*")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
×
</button>
{/* Row 3 */}
<button
type="button"
onClick={() => handleDigit("4")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
4
</button>
<button
type="button"
onClick={() => handleDigit("5")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
5
</button>
<button
type="button"
onClick={() => handleDigit("6")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
6
</button>
<button
type="button"
onClick={() => handleOperator("-")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
{/* Row 4 */}
<button
type="button"
onClick={() => handleDigit("1")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
1
</button>
<button
type="button"
onClick={() => handleDigit("2")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
2
</button>
<button
type="button"
onClick={() => handleDigit("3")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
3
</button>
<button
type="button"
onClick={() => handleOperator("+")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
+
</button>
{/* Row 5 */}
<button
type="button"
onClick={() => handleDigit("0")}
className="col-span-2 h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
0
</button>
<button
type="button"
onClick={handleDecimal}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-lg shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
.
</button>
<button
type="button"
onClick={handleEquals}
className="h-11 rounded-clay-sm bg-clay-primary text-clay-on-primary border border-clay-primary-dark/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
=
</button>
</div>
{/* Done Action Button */}
<Button
id="btn-calc-done"
type="button"
variant="primary"
fullWidth
onClick={handleDone}
className="mt-1 py-3 text-base font-baloo font-bold shadow-clay-raised"
>
<span className="flex items-center justify-center gap-2">
<CheckIcon size={18} />
<span>{t("transaction.calcDone") || "Xong"}</span>
</span>
</Button>
</div>
</div>
);
};
export default CalculatorModal;
export * from "@/components/shared/CalculatorModal";
export { CalculatorModal as default } from "@/components/shared/CalculatorModal";
......@@ -8,14 +8,34 @@ export const Card: React.FC<CardProps> = ({
children,
hoverable = false,
className = "",
onClick,
onKeyDown,
role,
tabIndex,
...props
}) => {
const isClickable = Boolean(onClick);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (onKeyDown) {
onKeyDown(event);
}
if (!event.defaultPrevented && isClickable && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}
};
return (
<div
role={role ?? (isClickable ? "button" : undefined)}
tabIndex={tabIndex ?? (isClickable ? 0 : undefined)}
onClick={onClick}
onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined}
className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6
transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className}
`}
{...props}
......
......@@ -19,17 +19,25 @@ export const Modal: React.FC<ModalProps> = ({
footer,
}) => {
const { t } = useI18n();
// Prevent background scrolling when open
// Prevent background scrolling when open and handle Escape key
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
if (!isOpen) {
document.body.style.overflow = "unset";
return undefined;
}
document.body.style.overflow = "hidden";
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = "unset";
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
}, [isOpen, onClose]);
if (!isOpen) return null;
......@@ -43,6 +51,9 @@ export const Modal: React.FC<ModalProps> = ({
{/* Modal Container */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="
relative w-full max-w-lg 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-6 flex flex-col gap-4
......@@ -51,7 +62,7 @@ export const Modal: React.FC<ModalProps> = ({
>
{/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3>
<h3 id="modal-title" className="clay-title-h3">{title}</h3>
<button
type="button"
onClick={onClose}
......
......@@ -21,7 +21,9 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
id,
...props
}, ref) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
const generatedId = React.useId();
const selectId = id || generatedId;
const errorId = `${selectId}-error`;
return (
<div className="flex flex-col gap-2 w-full relative">
......@@ -34,6 +36,8 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<select
id={selectId}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={`
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10
rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none
......@@ -56,7 +60,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
</div>
</div>
{error && (
<span className="font-nunito text-xs text-clay-expense px-1">
<span id={errorId} className="font-nunito text-xs text-clay-expense px-1">
{error}
</span>
)}
......
......@@ -20,6 +20,7 @@ export const Tabs: React.FC<TabsProps> = ({
}) => {
return (
<div
role="tablist"
className={`
bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none
${className}
......@@ -30,6 +31,9 @@ export const Tabs: React.FC<TabsProps> = ({
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.key)}
className={`
flex-1 text-center py-2 px-2 sm:px-4 text-xs sm:text-sm leading-tight font-baloo font-semibold rounded-full
......
......@@ -99,6 +99,12 @@
--shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45);
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html,
body,
#app {
......@@ -108,9 +114,12 @@ body,
body {
font-family: "Nunito", sans-serif;
font-size: 16px;
margin: 0;
color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
transition:
background-color 200ms ease-in-out,
color 200ms ease-in-out;
......@@ -133,15 +142,15 @@ body {
.clay-title-h1 {
font-family: "Baloo 2", sans-serif;
font-weight: 700;
font-size: 32px;
line-height: 1.2;
font-size: clamp(24px, 6vw, 30px);
line-height: 1.25;
color: rgb(var(--color-clay-text));
}
.clay-title-h2 {
font-family: "Baloo 2", sans-serif;
font-weight: 600;
font-size: 24px;
font-size: clamp(19px, 5vw, 24px);
line-height: 1.3;
color: rgb(var(--color-clay-text));
}
......@@ -149,8 +158,8 @@ body {
.clay-title-h3 {
font-family: "Baloo 2", sans-serif;
font-weight: 600;
font-size: 18px;
line-height: 1.4;
font-size: clamp(15px, 4vw, 18px);
line-height: 1.35;
color: rgb(var(--color-clay-text));
}
......@@ -259,7 +268,14 @@ body {
color 200ms ease-in-out;
}
.zaui-header .zaui-header-title,
.zaui-header .zaui-header-title {
color: rgb(var(--color-clay-text));
transition: color 200ms ease-in-out;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zaui-header .zaui-header-back-btn,
.zaui-header .zaui-header-back-btn .zaui-icon {
color: rgb(var(--color-clay-text));
......@@ -276,12 +292,26 @@ body {
);
}
/* Reserve space for native right-buttons (96px), three 36px controls, and gaps. */
/* Reserve space for native right-buttons (96px), three controls, and gaps. */
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
);
}
@media (max-width: 480px) {
.finwise-header-controls {
gap: 4px;
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 88px
);
}
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
);
}
}
input.finwise-localized-date {
color: transparent;
-webkit-text-fill-color: transparent;
......
......@@ -37,7 +37,7 @@ export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, data }: { key: string; data: { value: any; description?: string } }) =>
mutationFn: ({ key, data }: { key: string; data: { value: unknown; description?: string } }) =>
adminSettingsService.updateSetting(key, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
......
......@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { API_BASE_URL } from "@/lib/api-client";
import { useAuthStore } from "@/stores/auth-store";
import { notificationKeys } from "@/hooks/use-notifications";
import { safeStorage } from "@/lib/storage";
import { NotificationItem } from "@/types/notification";
export interface UseNotificationSseOptions {
......@@ -27,13 +28,15 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
return;
}
const token = accessToken || localStorage.getItem("accessToken");
const token = accessToken || safeStorage.getItem("accessToken");
const streamUrl = new URL(`${API_BASE_URL}/notifications/stream`);
if (token) {
streamUrl.searchParams.set("token", token);
}
let eventSource: EventSource | null = null;
let retryCount = 0;
const MAX_RETRIES = 5;
try {
eventSource = new EventSource(streamUrl.toString(), {
......@@ -41,10 +44,12 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
});
eventSource.addEventListener("open", () => {
retryCount = 0;
setIsConnected(true);
});
eventSource.addEventListener("connected", () => {
retryCount = 0;
setIsConnected(true);
});
......@@ -78,9 +83,13 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
eventSource.onerror = () => {
setIsConnected(false);
// If the connection was rejected (e.g. token expired or unauthorized)
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
retryCount += 1;
// If unauthenticated or exceeded max retry attempts, close connection to prevent reconnection loop
if (retryCount >= MAX_RETRIES || !useAuthStore.getState().isAuthenticated) {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
};
} catch (error) {
......
......@@ -8,6 +8,8 @@ import {
UpdateSavingContributionInput,
UpdateSavingGoalInput,
} from "@/types/saving-goal";
import { walletKeys } from "./use-wallets";
import { reportKeys } from "./use-reports";
export const savingGoalKeys = {
all: ["saving-goals"] as const,
......@@ -96,6 +98,8 @@ function useContributionMutation(id: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: savingGoalKeys.contributions(id) }),
queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
]);
};
}
......
......@@ -3,6 +3,8 @@ import { transactionService } from "@/services/transaction.service";
import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction";
import { walletKeys } from "./use-wallets";
import { budgetKeys } from "./use-budgets";
import { reportKeys } from "./use-reports";
import { FORECAST_QUERY_KEYS } from "./use-forecast";
export const transactionKeys = {
all: ["transactions"] as const,
......@@ -39,6 +41,8 @@ export function useCreateTransaction() {
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]);
},
});
......@@ -54,6 +58,8 @@ export function useUpdateTransaction(id: string) {
queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]);
},
});
......@@ -68,6 +74,8 @@ export function useDeleteTransaction() {
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]);
},
});
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { transferService } from "@/services/transfer.service";
import { CreateTransferInput, TransferQuery } from "@/types/transfer";
import { reportKeys } from "@/hooks/use-reports";
import { transactionKeys } from "@/hooks/use-transactions";
import { walletKeys } from "@/hooks/use-wallets";
......@@ -31,6 +32,7 @@ function useTransferMutation<TVariables>(
queryClient.invalidateQueries({ queryKey: transferKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
]);
},
});
......
......@@ -40,23 +40,25 @@ export function useZaloLogin(): UseZaloLoginReturn {
}
try {
const authResult = await authorize({ scopes: ["scope.userInfo"] });
console.log("[ZaloLogin] authorize result:", authResult);
await authorize({ scopes: ["scope.userInfo"] });
} catch (authErr) {
console.warn("[ZaloLogin] authorize scope.userInfo error or dismissed:", authErr);
}
try {
const infoResult: any = await getUserInfo({ autoRequestPermission: true });
console.log("[ZaloLogin] getUserInfo raw result:", infoResult);
const userObj = infoResult?.userInfo || infoResult;
const rawInfo = (await getUserInfo({ autoRequestPermission: true })) as unknown as {
userInfo?: { id?: string; name?: string; avatar?: string };
id?: string;
name?: string;
avatar?: string;
};
const userObj = rawInfo?.userInfo || rawInfo;
if (userObj) {
if (userObj.id) zaloId = userObj.id;
if (userObj.name) name = userObj.name;
if (userObj.avatar) avatar = userObj.avatar;
}
console.log("[ZaloLogin] resolved user profile:", { zaloId, name, avatar });
} catch (infoErr: any) {
} catch (infoErr) {
console.warn("[ZaloLogin] getUserInfo error:", infoErr);
}
......
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { normalizeDateTimeFormatOptions } from "@/lib/date-format";
import { safeStorage } from "@/lib/storage";
import en from "./locales/en.json";
import vi from "./locales/vi.json";
......@@ -28,12 +29,8 @@ interface I18nContextValue {
const I18nContext = createContext<I18nContextValue | null>(null);
function readStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
const stored = safeStorage.getItem(LOCALE_STORAGE_KEY);
return stored && stored in resources ? stored as Locale : "vi";
} catch {
return "vi";
}
}
function resolveTranslation(locale: Locale, key: string): string {
......@@ -59,11 +56,7 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
const setLocale = useCallback((nextLocale: Locale) => {
setLocaleState(nextLocale);
try {
localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
} catch {
// Language switching still works when storage is unavailable in a restricted webview.
}
safeStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
}, []);
const toggleLocale = useCallback(() => {
......
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
import { safeStorage } from "@/lib/storage";
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:7777/api/v1";
const apiBaseUrl = API_BASE_URL;
......@@ -27,7 +28,7 @@ const refreshClient = axios.create({
// Request interceptor to attach bearer token
apiClient.interceptors.request.use(
(config) => {
const accessToken = useAuthStore.getState().accessToken || localStorage.getItem("accessToken");
const accessToken = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
......@@ -39,10 +40,15 @@ apiClient.interceptors.request.use(
);
// Response interceptor to handle token refresh
interface QueuedRequest {
resolve: (token: string | null) => void;
reject: (error: unknown) => void;
}
let isRefreshing = false;
let failedQueue: any[] = [];
let failedQueue: QueuedRequest[] = [];
const processQueue = (error: any, token: string | null = null) => {
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
......@@ -65,7 +71,10 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes('/auth/register') ||
originalRequest.url?.includes('/auth/refresh') ||
originalRequest.url?.includes('/auth/forgot-password') ||
originalRequest.url?.includes('/auth/reset-password');
originalRequest.url?.includes('/auth/reset-password') ||
originalRequest.url?.includes('/auth/zalo-login') ||
originalRequest.url?.includes('/auth/me') ||
originalRequest.url?.includes('/auth/logout');
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
......@@ -74,12 +83,13 @@ apiClient.interceptors.response.use(
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string | null) => {
originalRequest._retry = true;
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(apiClient(originalRequest));
},
reject: (err: any) => {
reject: (err: unknown) => {
reject(err);
},
});
......@@ -90,7 +100,7 @@ apiClient.interceptors.response.use(
isRefreshing = true;
try {
const storedRefreshToken = useAuthStore.getState().refreshToken || localStorage.getItem("refreshToken");
const storedRefreshToken = useAuthStore.getState().refreshToken || safeStorage.getItem("refreshToken");
// Try refreshing the token (cookies will be sent automatically, body as fallback for mobile webviews)
const response = await refreshClient.post("/auth/refresh", {
refreshToken: storedRefreshToken || undefined,
......@@ -98,16 +108,17 @@ apiClient.interceptors.response.use(
if (response.data?.success) {
const newData = response.data?.data;
let newAccessToken = null;
let newAccessToken: string | null = null;
if (newData && newData.accessToken) {
newAccessToken = newData.accessToken;
if (newData && typeof newData.accessToken === "string") {
const tokenStr = newData.accessToken;
newAccessToken = tokenStr;
useAuthStore.getState().setAuth(
useAuthStore.getState().user,
newAccessToken,
newData.refreshToken
tokenStr,
newData.refreshToken || ""
);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
originalRequest.headers.Authorization = `Bearer ${tokenStr}`;
}
processQueue(null, newAccessToken);
......
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
export const positiveAmountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
export function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
......@@ -16,6 +18,8 @@ export function formatMoneyInput(value: string, locale: string): string {
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
export const formatAmountInput = formatMoneyInput;
export function parseMoneyInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
......@@ -40,6 +44,8 @@ export function parseMoneyInput(value: string, locale: string): string {
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
export const parseAmountInput = parseMoneyInput;
export function toLocalDate(value?: string): string {
if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
......@@ -48,4 +54,3 @@ export function toLocalDate(value?: string): string {
export function toLocalDateTime(value?: string): string {
return instantToBusinessDateTimeInput(value);
}
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
/**
* Safe wrapper for localStorage that gracefully falls back to an in-memory
* map when localStorage is unavailable or blocked by browser security policies.
*/
const memoryStore = new Map<string, string>();
function isLocalStorageAvailable(): boolean {
try {
if (typeof window === "undefined" || !window.localStorage) {
return false;
}
const testKey = "__finwise_storage_test__";
window.localStorage.setItem(testKey, "1");
window.localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
const canUseLocalStorage = isLocalStorageAvailable();
export const safeStorage = {
getItem(key: string): string | null {
if (canUseLocalStorage) {
try {
return window.localStorage.getItem(key);
} catch {
return memoryStore.get(key) ?? null;
}
}
return memoryStore.get(key) ?? null;
},
setItem(key: string, value: string): void {
if (canUseLocalStorage) {
try {
window.localStorage.setItem(key, value);
return;
} catch {
// Fall through to in-memory store
}
}
memoryStore.set(key, value);
},
removeItem(key: string): void {
if (canUseLocalStorage) {
try {
window.localStorage.removeItem(key);
} catch {
// Fall through to in-memory store
}
}
memoryStore.delete(key);
},
clear(): void {
if (canUseLocalStorage) {
try {
window.localStorage.clear();
} catch {
// Fall through to in-memory store
}
}
memoryStore.clear();
},
};
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
......@@ -32,13 +32,15 @@ import {
AiRequestStatus,
} from '@/services/admin-ai.service';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
type TabType = 'status' | 'usage' | 'logs' | 'rate-limit';
export const AdminAiPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const { openSnackbar } = useSnackbar();
const { t, formatNumber, formatDate } = useI18n();
const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.AI_CONFIG_UPDATE);
const canReadUsage = hasPermission(PERMISSIONS.AI_USAGE_READ);
......@@ -88,8 +90,15 @@ export const AdminAiPage: React.FC = () => {
featureKey: feature.key,
enabled: !feature.enabled,
});
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.ai.features.toggleError') || 'Chuyển đổi trạng thái tính năng AI thất bại');
openSnackbar({
type: 'success',
text: t('admin.ai.features.toggleSuccess') || 'Đã cập nhật trạng thái tính năng AI',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.ai.features.toggleError') || 'Chuyển đổi trạng thái tính năng AI thất bại'),
});
}
};
......@@ -100,9 +109,15 @@ export const AdminAiPage: React.FC = () => {
windowMs: Number(rateLimitWindowMinutes) * 60 * 1000,
});
setRateLimitDirty(false);
alert(t('admin.ai.rateLimit.success') || 'Đã cập nhật giới hạn AI Rate Limit thành công');
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.ai.rateLimit.error') || 'Cập nhật giới hạn thất bại');
openSnackbar({
type: 'success',
text: t('admin.ai.rateLimit.success') || 'Đã cập nhật giới hạn AI Rate Limit thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.ai.rateLimit.error') || 'Cập nhật giới hạn thất bại'),
});
}
};
......@@ -405,7 +420,7 @@ export const AdminAiPage: React.FC = () => {
</div>
<span className="text-[11px] text-clay-text-muted">
{new Date(log.createdAt).toLocaleString()}
{log.createdAt ? formatDate(log.createdAt, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ''}
</span>
</div>
......
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
......@@ -31,6 +31,7 @@ import {
NotificationDeliveryStatus,
} from '@/services/admin-notification.service';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
function extractTemplateVariables(item?: { titleTemplate?: string; bodyTemplate?: string; variables?: string[] } | null): string[] {
......@@ -48,7 +49,8 @@ type TabType = 'overview' | 'deliveries' | 'templates' | 'channels';
export const AdminNotificationsPage: React.FC = () => {
const navigate = useNavigate();
const { t, formatNumber } = useI18n();
const { openSnackbar } = useSnackbar();
const { t, formatNumber, formatDate } = useI18n();
const { hasPermission } = usePermission();
const canRetry = hasPermission(PERMISSIONS.NOTIFICATION_RETRY);
const canUpdateTemplate = hasPermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE);
......@@ -62,6 +64,10 @@ export const AdminNotificationsPage: React.FC = () => {
const [searchDelivery, setSearchDelivery] = useState('');
const [deliveryPage, setDeliveryPage] = useState(1);
React.useEffect(() => {
setDeliveryPage(1);
}, [filterChannel, filterStatus, searchDelivery]);
// Template editing
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplateItem | null>(null);
const [editTitleTemplate, setEditTitleTemplate] = useState('');
......@@ -106,8 +112,15 @@ export const AdminNotificationsPage: React.FC = () => {
const handleRetry = async (deliveryId: string) => {
try {
await retryMutation.mutateAsync(deliveryId);
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.notifications.deliveries.retryError') || 'Thử lại gửi thông báo thất bại');
openSnackbar({
type: 'success',
text: t('admin.notifications.deliveries.retrySuccess') || 'Đang gửi lại thông báo',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.deliveries.retryError') || 'Thử lại gửi thông báo thất bại'),
});
}
};
......@@ -130,8 +143,15 @@ export const AdminNotificationsPage: React.FC = () => {
},
});
setSelectedTemplate(null);
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.notifications.templates.error') || 'Cập nhật mẫu thông báo thất bại');
openSnackbar({
type: 'success',
text: t('admin.notifications.templates.success') || 'Cập nhật mẫu thông báo thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.templates.error') || 'Cập nhật mẫu thông báo thất bại'),
});
}
};
......@@ -139,11 +159,18 @@ export const AdminNotificationsPage: React.FC = () => {
setChannelConfigState((prev) => ({ ...prev, [key]: value }));
try {
await updateChannelsMutation.mutateAsync({ [key]: value });
} catch (err: any) {
openSnackbar({
type: 'success',
text: t('admin.notifications.channels.success') || 'Cập nhật kênh thông báo thành công',
});
} catch (err: unknown) {
if (channelsQuery.data?.data) {
setChannelConfigState(channelsQuery.data.data);
}
alert(err?.response?.data?.message || t('admin.notifications.channels.error') || 'Lưu cấu hình kênh thất bại');
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.channels.error') || 'Lưu cấu hình kênh thất bại'),
});
}
};
......@@ -358,7 +385,7 @@ export const AdminNotificationsPage: React.FC = () => {
</div>
<span className="text-[11px] text-clay-text-muted">
{item.createdAt ? new Date(item.createdAt).toLocaleString() : ''}
{item.createdAt ? formatDate(item.createdAt, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ''}
</span>
</div>
......
import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate';
......@@ -27,25 +27,21 @@ import {
import { SettingCategory, SystemSettingItem } from '@/services/admin-settings.service';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from '@/lib/business-time';
import { getErrorMessage } from '@/lib/error-message';
function toDatetimeLocalValue(isoOrDateString?: string | null): string {
if (!isoOrDateString) return '';
const date = new Date(isoOrDateString);
if (isNaN(date.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
return instantToBusinessDateTimeInput(isoOrDateString);
}
function toIsoValue(datetimeLocalString?: string | null): string | null {
if (!datetimeLocalString || !datetimeLocalString.trim()) return null;
const date = new Date(datetimeLocalString);
if (isNaN(date.getTime())) return null;
return date.toISOString();
try {
return businessWallTimeToIso(datetimeLocalString.trim());
} catch {
return null;
}
}
const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [
......@@ -59,6 +55,7 @@ const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [
export const AdminSettingsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t } = useI18n();
const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE);
......@@ -151,8 +148,8 @@ export const AdminSettingsPage: React.FC = () => {
},
});
setSelectedSetting(null);
} catch (err: any) {
setEditError(err?.response?.data?.message || t('admin.settings.modal.error') || 'Cập nhật cấu hình thất bại');
} catch (err: unknown) {
setEditError(getErrorMessage(err, t('admin.settings.modal.error') || 'Cập nhật cấu hình thất bại'));
}
};
......@@ -165,9 +162,15 @@ export const AdminSettingsPage: React.FC = () => {
endAt: toIsoValue(maintEndAt),
});
setMaintenanceDirty(false);
alert(t('admin.settings.maintenance.success') || 'Đã cập nhật cấu hình bảo trì hệ thống thành công');
} catch (err: any) {
alert(err?.response?.data?.message || t('admin.settings.maintenance.error') || 'Cập nhật chế độ bảo trì thất bại');
openSnackbar({
type: 'success',
text: t('admin.settings.maintenance.success') || 'Đã cập nhật cấu hình bảo trì hệ thống thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.settings.maintenance.error') || 'Cập nhật chế độ bảo trì thất bại'),
});
}
};
......
......@@ -28,7 +28,7 @@ import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
const PAGE_SIZE = 10;
const createUserSchema = z.object({
email: z.string().email().refine((value) => value.endsWith('@gmail.com')),
email: z.string().email(),
password: z.string().min(8).regex(/[a-z]/).regex(/[A-Z]/).regex(/[0-9]/).regex(/[^a-zA-Z0-9]/),
roleId: z.string().min(1),
});
......@@ -49,6 +49,10 @@ const AdminUsersPage: React.FC = () => {
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
React.useEffect(() => {
setPage(1);
}, [deferredSearch, roleName, activeFilter]);
const listParams = useMemo(() => ({
...(deferredSearch.includes('@')
? { email: deferredSearch }
......
......@@ -47,15 +47,29 @@ export const AIChatView: React.FC = () => {
scrollToBottom();
}, [messages.length, messages[messages.length - 1]?.streamingText]);
const streamingIntervalsRef = useRef<Record<string, ReturnType<typeof setInterval>>>({});
useEffect(() => {
return () => {
Object.values(streamingIntervalsRef.current).forEach((interval) => clearInterval(interval));
streamingIntervalsRef.current = {};
};
}, []);
// Simulated Streaming effect for AI text response
const streamAIResponse = (msgId: string, fullText: string) => {
let index = 0;
const speedMs = 15; // smooth typing interval
if (streamingIntervalsRef.current[msgId]) {
clearInterval(streamingIntervalsRef.current[msgId]);
}
const interval = setInterval(() => {
index += 3; // type 3 chars per interval
if (index >= fullText.length) {
clearInterval(interval);
delete streamingIntervalsRef.current[msgId];
updateMessage(msgId, {
text: fullText,
streamingText: undefined,
......@@ -65,6 +79,8 @@ export const AIChatView: React.FC = () => {
updateMessage(msgId, { streamingText: fullText.slice(0, index) });
}
}, speedMs);
streamingIntervalsRef.current[msgId] = interval;
};
const handleSend = (textToSend?: string) => {
......
......@@ -16,27 +16,20 @@ import { useZaloLogin } from "@/hooks/use-zalo-login";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { getErrorMessage } from "@/lib/error-message";
import { safeStorage } from "@/lib/storage";
import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const getRememberedEmail = (): string => {
try {
return localStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
} catch {
return "";
}
return safeStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
};
const updateRememberedEmail = (email: string, shouldRemember: boolean): void => {
try {
if (shouldRemember) {
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
safeStorage.setItem(REMEMBERED_EMAIL_KEY, email);
} else {
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Storage can be unavailable in restricted webviews; login should still succeed.
safeStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
};
......
......@@ -16,49 +16,10 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return value;
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
[integerDisplay, decimalDisplay] = trimmedValue.split(group).join("").split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
function localDate(value?: string): string {
if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
......
......@@ -37,7 +37,7 @@ function HomePage() {
sortBy: "startDate",
order: "desc",
page: 1,
limit: 100,
limit: 25,
});
const { budgetAlertCount, hasExceededBudget } = useMemo(() => {
......@@ -64,7 +64,7 @@ function HomePage() {
const recurringQuery = useRecurringTransactions({
isActive: true,
page: 1,
limit: 100,
limit: 25,
});
const discoveryQuery = useDiscoveredSubscriptions();
......@@ -104,7 +104,7 @@ function HomePage() {
<Header title={t("home.header")} showBackIcon={false} />
<IconGradients />
<div className="flex flex-col items-center justify-start gap-5 px-4 pt-2 pb-6">
<div className="flex flex-col items-center justify-start gap-4 pt-2 pb-6">
{/* Logo/Avatar Area */}
<div className="relative">
<Avatar
......@@ -120,9 +120,9 @@ function HomePage() {
</div>
{/* Text Area */}
<div className="text-center space-y-2">
<div className="text-center space-y-1.5 max-w-sm mx-auto">
<h1 className="clay-title-h1 text-clay-primary">{t("home.greeting", { name: user?.fullName || t("common.user") })}</h1>
<h2 className="clay-title-h3 text-clay-text">{t("home.subtitle")}</h2>
<h2 className="clay-title-h3 text-clay-text [text-wrap:balance]">{t("home.subtitle")}</h2>
<p className="clay-caption max-w-xs mx-auto">
{t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span>
</p>
......@@ -130,7 +130,7 @@ function HomePage() {
</div>
{/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3 pb-12">
<div className="w-full max-w-sm mx-auto flex flex-col gap-3 pb-12">
<PermissionGate permission={PERMISSIONS.USER_READ}>
<button
type="button"
......
import React from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
const NotFoundPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Page className="page min-h-screen bg-clay-bg flex flex-col">
<Header title="404" showBackIcon onBackClick={() => navigate("/")} />
<div className="flex-1 flex items-center justify-center p-4">
<Card className="max-w-md w-full p-8 text-center flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-clay-lg bg-clay-warning/15 text-clay-warning flex items-center justify-center shadow-clay-pressed text-3xl font-bold font-baloo">
404
</div>
<div>
<h1 className="clay-title-h2">Không tìm thấy trang</h1>
<p className="clay-caption mt-2 text-clay-text-muted">
Đường dẫn bạn yêu cầu không tồn tại hoặc đã được di chuyển.
</p>
</div>
<Button
variant="primary"
className="mt-2 px-6"
onClick={() => navigate("/", { replace: true })}
>
Quay lại trang chủ
</Button>
</Card>
</div>
</Page>
);
};
export default NotFoundPage;
......@@ -337,6 +337,7 @@ const ProfilePage: React.FC = () => {
} catch (err) {
// Still clear local auth state if logout call fails (e.g. server down)
} finally {
queryClient.clear();
clearAuth();
navigate("/login", { replace: true });
}
......
......@@ -28,52 +28,7 @@ import {
} from "@/components/ui/icons";
import { Logo } from "@/components/logo";
import { useI18n } from "@/i18n";
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return "";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
const localeNormalized = trimmedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
if (!integerDigits && decimalDisplay === undefined) return "";
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const StyleGuidePage: React.FC = () => {
const navigate = useNavigate();
......
import React from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { DiscoveredSubscription } from "@/types/subscription";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
interface SubscriptionCardProps {
item: DiscoveredSubscription;
onConvertToReminder: (item: DiscoveredSubscription) => void;
onConvertToRecurring: (item: DiscoveredSubscription) => void;
isConverting?: boolean;
isCreatingRecurring?: boolean;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
item,
onConvertToReminder,
onConvertToRecurring,
isConverting,
isCreatingRecurring,
}) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
const latestAmount = parseFloat(item.latestAmount);
return (
<Card className="space-y-3 p-4 shadow-clay-raised">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-clay-text text-sm font-baloo">{item.merchantName}</h4>
<Badge type="primary" className="text-[10px] px-2 py-0.5">
{t(`subscriptions.freq_${item.frequency}`)}
</Badge>
</div>
<p className="text-xs text-clay-text-muted mt-0.5 font-medium">
{getCategoryDisplayName({ name: item.categoryName }, t)}{item.occurrenceCount} {t("subscriptions.occurrences")}
</p>
</div>
<div className="text-right">
<p className="font-bold text-clay-primary text-sm">
{formatCurrency(latestAmount, item.currency)}
</p>
<span className="text-[10px] text-clay-text-muted block font-medium">
{t("subscriptions.confidence")}: {(item.confidenceScore * 100).toFixed(0)}%
</span>
</div>
</div>
{/* Price Drift Alert */}
{item.isPriceDrift && (
<div className="bg-clay-warning-soft border border-clay-warning/40 px-3 py-1.5 rounded-clay-sm flex items-center justify-between text-xs shadow-clay-pressed">
<span className="text-clay-text font-bold">
⚠️ {t("subscriptions.priceHikeAlert")}
</span>
<span className="font-bold text-clay-warning">
+{formatNumber(item.priceDriftPercentage ?? 0, { maximumFractionDigits: 1 })}%
</span>
</div>
)}
{/* Next Expected Billing Date */}
<div className="flex items-center justify-between pt-2 border-t border-clay-border/40 text-xs">
<span className="text-clay-text-muted font-medium">
{t("subscriptions.nextBilling")}: {" "}
<b className="text-clay-text font-bold">
{formatBusinessDate(item.nextExpectedAt, intlLocale, { day: "2-digit", month: "2-digit", year: "numeric" })}
</b>
</span>
{item.isLinkedToReminder ? (
<Badge type="income" className="text-[10px]">
{t("subscriptions.trackedInReminders")}
</Badge>
) : (
<Button
variant="secondary"
onClick={() => onConvertToReminder(item)}
disabled={isConverting}
className="text-[11px] py-1 px-3"
>
{isConverting ? t("common.processing") : t("subscriptions.convertToReminderBtn")}
</Button>
)}
</div>
<Button
variant="primary"
fullWidth
onClick={() => onConvertToRecurring(item)}
disabled={isCreatingRecurring}
className="text-xs py-2"
>
{isCreatingRecurring ? t("common.processing") : t("recurringTransactions.subscription.button")}
</Button>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const SubscriptionSkeleton: React.FC = () => (
<div className="space-y-3 animate-pulse" aria-hidden="true">
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
);
import React from "react";
import { Header, Page, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useI18n } from "@/i18n";
import { DiscoveredSubscription } from "@/types/subscription";
import { businessWallTimeToIso } from "@/lib/business-time";
import { SubscriptionCard } from "./components/SubscriptionCard";
import { SubscriptionSkeleton } from "./components/SubscriptionSkeleton";
import { useWallets } from "@/hooks/use-wallets";
import { useConvertSubscriptionToRecurring } from "@/hooks/use-recurring-transactions";
const SubscriptionsPage: React.FC = () => {
const { t } = useI18n();
const { openSnackbar } = useSnackbar();
const discoveryQuery = useDiscoveredSubscriptions();
const convertMutation = useConvertToReminder();
const recurringMutation = useConvertSubscriptionToRecurring();
const walletsQuery = useWallets({ includeArchived: false, sortBy: "name", order: "asc", page: 1, limit: 100 });
const [recurringCandidate, setRecurringCandidate] = React.useState<DiscoveredSubscription | null>(null);
const [walletId, setWalletId] = React.useState("");
const [reminderCandidate, setReminderCandidate] = React.useState<DiscoveredSubscription | null>(null);
const [remindDaysBefore, setRemindDaysBefore] = React.useState("2");
const [renewalDate, setRenewalDate] = React.useState("");
const items = discoveryQuery.data?.data.items || [];
const isLoading = discoveryQuery.isLoading;
const handleOpenReminderModal = (sub: DiscoveredSubscription) => {
setReminderCandidate(sub);
setRenewalDate(sub.nextExpectedAt);
const defaultDays = sub.frequency === "MONTHLY" ? "2" : sub.frequency === "YEARLY" ? "7" : "0";
setRemindDaysBefore(defaultDays);
};
const handleConfirmReminder = async () => {
if (!reminderCandidate) return;
try {
const remindDate = businessWallTimeToIso(`${renewalDate || reminderCandidate.nextExpectedAt}T09:00`);
await convertMutation.mutateAsync({
merchantName: reminderCandidate.merchantName,
amount: reminderCandidate.latestAmount,
currency: reminderCandidate.currency,
frequency: reminderCandidate.frequency,
remindAt: remindDate,
remindDaysBefore: parseInt(remindDaysBefore, 10),
categoryId: reminderCandidate.categoryId,
});
openSnackbar({
type: "success",
text: t("subscriptions.convertSuccess", { name: reminderCandidate.merchantName }),
});
setReminderCandidate(null);
} catch {
openSnackbar({
type: "error",
text: t("common.error"),
});
}
};
React.useEffect(() => {
if (!recurringCandidate || walletId) return;
const wallets = walletsQuery.data?.data || [];
const preferred = wallets.find((wallet) => wallet.isDefault) || wallets[0];
if (preferred) setWalletId(preferred.id);
}, [recurringCandidate, walletId, walletsQuery.data?.data]);
const handleRecurringConvert = async () => {
if (!recurringCandidate) return;
if (!walletId) {
openSnackbar({ type: "error", text: t("recurringTransactions.subscription.walletRequired") });
return;
}
try {
await recurringMutation.mutateAsync({
merchantName: recurringCandidate.merchantName,
walletId,
categoryId: recurringCandidate.categoryId,
amount: recurringCandidate.latestAmount,
frequency: recurringCandidate.frequency,
nextExpectedAt: recurringCandidate.nextExpectedAt,
});
openSnackbar({ type: "success", text: t("recurringTransactions.subscription.success", { name: recurringCandidate.merchantName }) });
setRecurringCandidate(null);
setWalletId("");
} catch {
openSnackbar({ type: "error", text: t("common.error") });
}
};
return (
<Page className="page min-h-screen pb-12 bg-clay-bg">
<Header title={t("subscriptions.pageTitle")} showBackIcon={true} />
<div className="p-4 space-y-5 max-w-md mx-auto">
{/* Banner */}
<Card className="p-4 space-y-1 bg-clay-surface border border-clay-primary/30 shadow-clay-raised">
<h3 className="font-bold text-sm text-clay-primary font-baloo">
{t("subscriptions.bannerTitle")}
</h3>
<p className="text-xs text-clay-text-muted leading-relaxed font-medium">
{t("subscriptions.bannerDesc")}
</p>
</Card>
{/* Subscriptions List */}
<div className="space-y-3">
<h3 className="clay-title-h3 text-clay-text text-sm font-baloo">
{t("subscriptions.discoveredTitle")} ({items.length})
</h3>
{isLoading && <SubscriptionSkeleton />}
{!isLoading && discoveryQuery.isError && (
<Card className="space-y-3 border border-clay-expense/30 bg-clay-expense-soft p-5 text-center">
<p className="text-xs font-bold text-clay-expense">{t("subscriptions.fetchError")}</p>
<button
type="button"
onClick={() => void discoveryQuery.refetch()}
className="rounded-clay-sm bg-clay-surface px-4 py-2 font-baloo text-sm font-bold text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out active:translate-y-[2px] active:shadow-clay-pressed"
>
{t("common.retry")}
</button>
</Card>
)}
{!isLoading && !discoveryQuery.isError && items.length === 0 && (
<Card className="text-center py-6 text-clay-text-muted space-y-1 p-5">
<p className="text-sm font-bold text-clay-text">{t("subscriptions.noSubscriptionsTitle")}</p>
<p className="text-xs">{t("subscriptions.noSubscriptionsDesc")}</p>
</Card>
)}
{!isLoading && !discoveryQuery.isError && items.length > 0 && (
<div className="space-y-3">
{items.map((item) => (
<SubscriptionCard
key={`${item.merchantName}-${item.categoryId}-${item.frequency}`}
item={item}
onConvertToReminder={handleOpenReminderModal}
onConvertToRecurring={(candidate) => { setWalletId(""); setRecurringCandidate(candidate); }}
isConverting={convertMutation.isPending && convertMutation.variables?.merchantName === item.merchantName}
isCreatingRecurring={recurringMutation.isPending && recurringCandidate?.merchantName === item.merchantName}
/>
))}
</div>
)}
</div>
</div>
{/* Reminder Conversion Modal */}
<Modal
isOpen={Boolean(reminderCandidate)}
onClose={() => setReminderCandidate(null)}
title={t("subscriptions.reminderModalTitle", { name: reminderCandidate?.merchantName || "" })}
footer={
<>
<Button
variant="ghost"
className="text-sm"
disabled={convertMutation.isPending}
onClick={() => setReminderCandidate(null)}
>
{t("common.cancel")}
</Button>
<Button
className="text-sm"
disabled={convertMutation.isPending || !renewalDate}
onClick={() => void handleConfirmReminder()}
>
{convertMutation.isPending ? t("common.processing") : t("subscriptions.reminderModalConfirm")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-clay-text-muted">
{t("subscriptions.reminderModalDesc")}
</p>
<Input
type="date"
label={t("subscriptions.renewalDateLabel")}
value={renewalDate}
onChange={(e) => setRenewalDate(e.target.value)}
disabled={convertMutation.isPending}
/>
<Select
label={t("subscriptions.remindDaysBeforeLabel")}
value={remindDaysBefore}
onChange={(e) => setRemindDaysBefore(e.target.value)}
disabled={convertMutation.isPending}
options={[
{ value: "0", label: t("subscriptions.remindDaysBefore_0") },
{ value: "1", label: t("subscriptions.remindDaysBefore_1") },
{ value: "2", label: t("subscriptions.remindDaysBefore_2") },
{ value: "3", label: t("subscriptions.remindDaysBefore_3") },
{ value: "5", label: t("subscriptions.remindDaysBefore_5") },
{ value: "7", label: t("subscriptions.remindDaysBefore_7") },
{ value: "14", label: t("subscriptions.remindDaysBefore_14") },
]}
/>
</div>
</Modal>
{/* Recurring Transaction Modal */}
<Modal
isOpen={Boolean(recurringCandidate)}
onClose={() => { setRecurringCandidate(null); setWalletId(""); }}
title={t("recurringTransactions.subscription.title", { name: recurringCandidate?.merchantName || "" })}
footer={<>
<Button variant="ghost" className="text-sm" disabled={recurringMutation.isPending} onClick={() => { setRecurringCandidate(null); setWalletId(""); }}>{t("common.cancel")}</Button>
<Button className="text-sm" disabled={recurringMutation.isPending || !walletId} onClick={() => void handleRecurringConvert()}>{recurringMutation.isPending ? t("common.processing") : t("recurringTransactions.subscription.confirm")}</Button>
</>}
>
<div className="space-y-4">
<p className="text-sm text-clay-text-muted">{t("recurringTransactions.subscription.description")}</p>
<Select
label={t("recurringTransactions.subscription.wallet")}
value={walletId}
onChange={(event) => setWalletId(event.target.value)}
disabled={walletsQuery.isLoading || recurringMutation.isPending}
options={[
{ value: "", label: t("recurringTransactions.form.selectWallet") },
...(walletsQuery.data?.data || []).filter((wallet) => !wallet.isArchived).map((wallet) => ({ value: wallet.id, label: `${wallet.name} (${wallet.currency})` })),
]}
/>
</div>
</Modal>
</Page>
);
};
export default SubscriptionsPage;
......@@ -15,58 +15,10 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) {
return value;
}
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const unsignedValue = trimmedValue;
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(decimal)) {
const localeNormalized = unsignedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(unsignedValue)) {
const separatorIndex = Math.max(unsignedValue.lastIndexOf("."), unsignedValue.lastIndexOf(","));
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
} else {
integerDisplay = unsignedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const createTransactionSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.transactionAmountRequired"))
.regex(amountPattern, t("validation.transactionAmountInvalid"))
......
......@@ -40,6 +40,7 @@ import {
Transaction,
TransactionQuery,
TransactionSortField,
TransactionType,
SortOrder,
UpdateTransactionInput,
} from "@/types/transaction";
......@@ -50,6 +51,7 @@ import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { ReceiptScannerView } from "../ai-assistant/components/ReceiptScannerView";
import { transactionService } from "@/services/transaction.service";
import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const PAGE_SIZE = 10;
......@@ -81,54 +83,7 @@ const getLocalDateString = (dateInput?: string | Date) => {
return instantToBusinessDate(dateInput);
};
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) {
return value;
}
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const unsignedValue = trimmedValue;
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(decimal)) {
const localeNormalized = unsignedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(unsignedValue)) {
const separatorIndex = Math.max(unsignedValue.lastIndexOf("."), unsignedValue.lastIndexOf(","));
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
} else {
integerDisplay = unsignedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const TransactionsPage: React.FC = () => {
const navigate = useNavigate();
......@@ -150,7 +105,7 @@ const TransactionsPage: React.FC = () => {
const deferredSearch = useDeferredValue(search.trim());
const [walletId, setWalletId] = useState(initialWalletId || "");
const [categoryId, setCategoryId] = useState(initialCategoryId || "");
const [type, setType] = useState(initialTransactionType || "");
const [type, setType] = useState<TransactionType | "">(initialTransactionType || "");
const [dateFrom, setDateFrom] = useState(initialDateFrom || "");
const [dateTo, setDateTo] = useState(initialDateTo || "");
const [minAmount, setMinAmount] = useState("");
......@@ -188,6 +143,7 @@ const TransactionsPage: React.FC = () => {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isReceiptScannerOpen, setIsReceiptScannerOpen] = useState(false);
const [isSubmittingTx, setIsSubmittingTx] = useState(false);
// Selected transactions
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | undefined>(undefined);
......@@ -223,7 +179,7 @@ const TransactionsPage: React.FC = () => {
const [sortBy, order] = sort.split(":") as [TransactionSortField, SortOrder];
const query = useMemo<TransactionQuery>(() => {
const q: any = {
const q: TransactionQuery = {
sortBy,
order,
page,
......@@ -310,30 +266,34 @@ const TransactionsPage: React.FC = () => {
});
}, [groupedTransactions, order]);
// Calculate Page Summary Metrics
// Calculate Page Summary Metrics grouped by currency
const pageSummary = useMemo(() => {
let income = 0;
let expense = 0;
const list = transactionsQuery.data?.data || [];
let activeCurrency = "VND";
const byCurrency: Record<string, { income: number; expense: number; net: number }> = {};
list.forEach((tx) => {
const amountNum = Number(tx.amount);
if (tx.wallet?.currency) {
activeCurrency = tx.wallet.currency;
const curr = tx.wallet?.currency || "VND";
if (!byCurrency[curr]) {
byCurrency[curr] = { income: 0, expense: 0, net: 0 };
}
const amountNum = Number(tx.amount);
if (tx.type === "INCOME") {
income += amountNum;
byCurrency[curr].income += amountNum;
byCurrency[curr].net += amountNum;
} else {
expense += amountNum;
byCurrency[curr].expense += amountNum;
byCurrency[curr].net -= amountNum;
}
});
const currencies = Object.keys(byCurrency);
return {
income: income.toString(),
expense: expense.toString(),
net: (income - expense).toString(),
currency: activeCurrency,
byCurrency,
currencies,
isSingleCurrency: currencies.length <= 1,
primary: currencies.length > 0
? { currency: currencies[0], ...byCurrency[currencies[0]] }
: { currency: "VND", income: 0, expense: 0, net: 0 },
};
}, [transactionsQuery.data?.data]);
......@@ -427,12 +387,14 @@ const TransactionsPage: React.FC = () => {
};
// CRUD handlers
const handleCreate = (
const handleCreate = async (
input: CreateTransactionInput | UpdateTransactionInput,
receiptFile: File | null
) => {
createMutation.mutate(input as CreateTransactionInput, {
onSuccess: async (response) => {
if (isSubmittingTx) return;
setIsSubmittingTx(true);
try {
const response = await createMutation.mutateAsync(input as CreateTransactionInput);
const newTx = response.data;
if (receiptFile) {
try {
......@@ -451,27 +413,27 @@ const TransactionsPage: React.FC = () => {
setIsCreateOpen(false);
setPrefilledData(undefined);
setPrefilledFile(null);
},
onError: (error) => {
} catch (error) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.createFailed")),
});
},
});
} finally {
setIsSubmittingTx(false);
}
};
const handleEdit = (
const handleEdit = async (
input: CreateTransactionInput | UpdateTransactionInput,
receiptFile: File | null,
deleteReceipt: boolean
) => {
if (!selectedTransaction) return;
if (!selectedTransaction || isSubmittingTx) return;
const txId = selectedTransaction.id;
setIsSubmittingTx(true);
transactionService.updateTransaction(txId, input as UpdateTransactionInput)
.then(async (response) => {
try {
await transactionService.updateTransaction(txId, input as UpdateTransactionInput);
if (deleteReceipt && selectedTransaction.receiptUrl) {
await transactionService.deleteReceipt(txId);
}
......@@ -483,22 +445,16 @@ const TransactionsPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
]);
openSnackbar({ type: "success", text: t("transaction.updateSuccess") });
} catch (uploadError) {
openSnackbar({
type: "warning",
text: `${t("transaction.updateSuccess")} ${t("transaction.receiptUploadFailed")}`,
});
}
setIsEditOpen(false);
setIsDetailOpen(false);
setSelectedTransaction(undefined);
})
.catch((error) => {
} catch (error) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("transaction.updateFailed")),
});
});
} finally {
setIsSubmittingTx(false);
}
};
const handleDelete = () => {
......@@ -529,7 +485,7 @@ const TransactionsPage: React.FC = () => {
/>
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-4 pb-16 px-4">
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-4 pb-16">
{/* Statistics Summary Card */}
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between border-b border-clay-highlight/25 pb-2">
......@@ -541,13 +497,14 @@ const TransactionsPage: React.FC = () => {
</span>
</div>
{pageSummary.currencies.length <= 1 ? (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="flex flex-col">
<span className="clay-caption text-clay-income font-bold">
{t("transaction.summary.income")}
</span>
<span className="font-baloo text-base font-bold text-clay-income mt-0.5">
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.income, pageSummary.currency, intlLocale)}
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.primary.income.toString(), pageSummary.primary.currency, intlLocale)}
</span>
</div>
......@@ -556,7 +513,7 @@ const TransactionsPage: React.FC = () => {
{t("transaction.summary.expense")}
</span>
<span className="font-baloo text-base font-bold text-clay-expense mt-0.5">
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.expense, pageSummary.currency, intlLocale)}
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.primary.expense.toString(), pageSummary.primary.currency, intlLocale)}
</span>
</div>
......@@ -565,12 +522,47 @@ const TransactionsPage: React.FC = () => {
{t("transaction.summary.net")}
</span>
<span className={`font-baloo text-base font-bold mt-0.5 ${
Number(pageSummary.net) >= 0 ? "text-clay-primary" : "text-clay-expense"
pageSummary.primary.net >= 0 ? "text-clay-primary" : "text-clay-expense"
}`}>
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.net, pageSummary.currency, intlLocale)}
{transactionsQuery.isLoading ? "..." : formatWalletBalance(pageSummary.primary.net.toString(), pageSummary.primary.currency, intlLocale)}
</span>
</div>
</div>
) : (
<div className="flex flex-col gap-2.5">
{pageSummary.currencies.map((curr) => {
const s = pageSummary.byCurrency[curr];
return (
<div key={curr} className="flex flex-col gap-1 border-t first:border-t-0 border-clay-highlight/25 pt-2 first:pt-0">
<div className="flex items-center justify-between">
<span className="font-baloo text-xs font-bold px-2 py-0.5 rounded-full bg-clay-primary/10 text-clay-primary">
{curr}
</span>
<span className={`font-baloo text-sm font-bold ${
s.net >= 0 ? "text-clay-primary" : "text-clay-expense"
}`}>
{formatWalletBalance(s.net.toString(), curr, intlLocale)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-center text-xs">
<div className="flex justify-between px-1">
<span className="clay-caption text-clay-income font-bold">{t("transaction.summary.income")}:</span>
<span className="font-baloo font-bold text-clay-income">
{formatWalletBalance(s.income.toString(), curr, intlLocale)}
</span>
</div>
<div className="flex justify-between px-1 border-l border-clay-highlight/25">
<span className="clay-caption text-clay-expense font-bold">{t("transaction.summary.expense")}:</span>
<span className="font-baloo font-bold text-clay-expense">
{formatWalletBalance(s.expense.toString(), curr, intlLocale)}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</Card>
{/* Title area & Create button */}
......@@ -688,7 +680,7 @@ const TransactionsPage: React.FC = () => {
{ value: "EXPENSE", label: t("category.type.expense") },
]}
value={type}
onChange={(e) => setType(e.target.value)}
onChange={(e) => setType(e.target.value as TransactionType | "")}
/>
<Select
......@@ -802,11 +794,20 @@ const TransactionsPage: React.FC = () => {
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
setSelectedTransaction(item);
setIsDetailOpen(true);
}}
className="flex cursor-pointer items-center justify-between gap-3 rounded-clay bg-clay-surface p-3.5 border border-clay-highlight/20 shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed transition-all duration-200 ease-in-out"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedTransaction(item);
setIsDetailOpen(true);
}
}}
className="flex cursor-pointer items-center justify-between gap-3 rounded-clay bg-clay-surface p-3.5 border border-clay-highlight/20 shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/40 transition-all duration-200 ease-in-out"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<CategoryArtwork
......@@ -918,7 +919,7 @@ const TransactionsPage: React.FC = () => {
isOpen={isCreateOpen}
initialValues={prefilledData}
initialReceiptFile={prefilledFile}
isSubmitting={createMutation.isPending}
isSubmitting={isSubmittingTx}
onClose={() => {
setIsCreateOpen(false);
setPrefilledData(undefined);
......@@ -931,7 +932,7 @@ const TransactionsPage: React.FC = () => {
<TransactionFormModal
isOpen={isEditOpen}
transaction={selectedTransaction}
isSubmitting={false} // Loading handled via detail triggers
isSubmitting={isSubmittingTx}
onClose={() => {
setIsEditOpen(false);
}}
......
......@@ -15,51 +15,10 @@ import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const separators = new Intl.NumberFormat(locale).format(1234.5).match(/[^\d]/g) || [];
return {
group: separators[0] || ",",
decimal: separators[separators.length - 1] || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return "";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
const normalized = trimmedValue.split(group).join("");
[integerDisplay, decimalDisplay] = normalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
function hasSufficientBalance(amount: string, balance: string): boolean {
if (balance.trim().startsWith("-")) return false;
......
......@@ -10,17 +10,10 @@ import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format";
import { Wallet, WalletInput } from "@/types/wallet";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { TranslationFunction, useI18n } from "@/i18n";
import { getNumberSeparators } from "@/lib/money-input";
const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatBalanceInput(value: string, locale: string, currency?: string): string {
if (!value || value === "-") {
return value;
......
......@@ -59,7 +59,7 @@ export const adminSettingsService = {
return response.data;
},
async updateSetting(key: string, data: { value: any; description?: string }): Promise<ApiResponse<SystemSettingItem>> {
async updateSetting(key: string, data: { value: unknown; description?: string }): Promise<ApiResponse<SystemSettingItem>> {
const response = await apiClient.patch(`/admin/settings/${key}`, data);
return response.data;
},
......
import axios from "axios";
import { apiClient } from "@/lib/api-client";
import {
AIChatInput,
......@@ -29,14 +30,14 @@ export class AIAssistantError extends Error {
}
}
function handleAIError(error: any): never {
if (error.response) {
function handleAIError(error: unknown): never {
if (axios.isAxiosError(error) && error.response) {
const status = error.response.status;
const data = error.response.data;
const data = error.response.data as { message?: string; code?: string } | undefined;
const headers = error.response.headers;
if (status === 429) {
const retryHeader = headers?.["retry-after"];
const retryHeader = headers?.["retry-after"] as string | undefined;
const retryAfter = retryHeader ? parseInt(retryHeader, 10) : 30;
throw new AIAssistantError(
data?.message || "AI request limit exceeded, please try again later",
......@@ -53,7 +54,11 @@ function handleAIError(error: any): never {
);
}
if (error instanceof Error) {
throw new AIAssistantError(error.message || "Network error while calling AI Assistant");
}
throw new AIAssistantError("Network error while calling AI Assistant");
}
export const aiAssistantService = {
......
import { apiClient } from "@/lib/api-client";
import { ApiResponse, LoginRequest, User, Session, UpdateProfileRequest, UpdateAvatarRequest, SessionQuery, SessionsResponse } from "@/types/auth";
import {
ApiResponse,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
Session,
SessionQuery,
SessionsResponse,
UpdateAvatarRequest,
UpdatePasswordRequest,
UpdateProfileRequest,
User,
} from "@/types/auth";
export const authService = {
async register(data: any): Promise<ApiResponse> {
async register(data: RegisterRequest): Promise<ApiResponse> {
const response = await apiClient.post("/auth/register", data);
return response.data;
},
......@@ -42,7 +54,7 @@ export const authService = {
return response.data;
},
async updatePassword(data: any): Promise<ApiResponse> {
async updatePassword(data: UpdatePasswordRequest): Promise<ApiResponse> {
const response = await apiClient.put("/auth/password", data);
return response.data;
},
......@@ -52,7 +64,7 @@ export const authService = {
return response.data;
},
async resetPassword(data: any): Promise<ApiResponse> {
async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse> {
const response = await apiClient.post("/auth/reset-password", data);
return response.data;
},
......
import { create } from "zustand";
import { User } from "@/types/auth";
import { useAIChatStore } from "@/stores/ai-chat-store";
import { queryClient } from "@/lib/query-client";
import { safeStorage } from "@/lib/storage";
interface AuthState {
user: User | null;
......@@ -15,9 +17,9 @@ interface AuthState {
}
export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from localStorage
const accessToken = localStorage.getItem("accessToken");
const refreshToken = localStorage.getItem("refreshToken");
// Pre-load tokens from safeStorage
const accessToken = safeStorage.getItem("accessToken");
const refreshToken = safeStorage.getItem("refreshToken");
return {
user: null,
......@@ -26,8 +28,8 @@ export const useAuthStore = create<AuthState>((set) => {
isAuthenticated: !!accessToken,
isInitialized: false,
setAuth: (user, accessToken, refreshToken) => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
safeStorage.setItem("accessToken", accessToken);
safeStorage.setItem("refreshToken", refreshToken);
set({
user,
accessToken,
......@@ -36,9 +38,10 @@ export const useAuthStore = create<AuthState>((set) => {
});
},
clearAuth: () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
safeStorage.removeItem("accessToken");
safeStorage.removeItem("refreshToken");
useAIChatStore.getState().clearMessages();
queryClient.clear();
set({
user: null,
accessToken: null,
......
import { create } from "zustand";
import { safeStorage } from "@/lib/storage";
export type ThemeMode = "light" | "dark";
......@@ -16,14 +17,10 @@ function getInitialTheme(): ThemeMode {
}
}
try {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
const storedTheme = safeStorage.getItem(THEME_STORAGE_KEY);
if (isThemeMode(storedTheme)) {
return storedTheme;
}
} catch {
// Storage may be unavailable in restricted webviews.
}
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
......@@ -56,12 +53,7 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
theme: initialTheme,
setTheme: (theme) => {
applyTheme(theme);
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Keep the selected theme for this session even when persistence is blocked.
}
safeStorage.setItem(THEME_STORAGE_KEY, theme);
set({ theme });
},
......
import { PaginationMeta } from "./wallet";
export interface Permission {
id: string;
name: string;
......@@ -97,7 +99,23 @@ export interface UpdateAvatarRequest {
avatarPositionY?: number;
}
import { PaginationMeta } from "./wallet";
export interface RegisterRequest {
email: string;
password: string;
fullName?: string;
phoneNumber?: string;
}
export interface UpdatePasswordRequest {
oldPassword?: string;
currentPassword?: string;
newPassword: string;
}
export interface ResetPasswordRequest {
token: string;
newPassword: string;
}
export interface SessionQuery {
page?: number;
......@@ -116,10 +134,10 @@ export interface SessionsResponse extends ApiResponse<Session[]> {
meta: PaginationMeta;
}
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
success: boolean;
message: string;
data: T;
errors?: any[] | null;
errors?: unknown[] | null;
}
import { TransactionType } from "./category";
export { TransactionType } from "./category";
export type TransactionSortField =
| "amount"
......
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