Commit 3636a672 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/frontend-i18n' into 'develop'

feat(i18n): add Vietnamese and English localization

See merge request !8
parents 0e308e0e 52e200d9
......@@ -11,9 +11,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng lên `data-theme``zaui-theme`, đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
- **Đa ngôn ngữ frontend**: UI hỗ trợ `vi``en` qua `src/i18n/`, lưu lựa chọn bằng khóa `finwise.locale` và dùng `Intl` với `vi-VN`/`en-US` cho tiền tệ, số và ngày. Chỉ dịch text hiển thị; enum, mã tiền tệ, ID và payload API giữ nguyên. Khi thêm locale mới, thêm resource/config locale và translation key tương ứng, không đưa text hiển thị trực tiếp vào component.
- **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống cho header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
- **Vite/ZMP entry**: Giữ `index.html` tại root repository và không cấu hình Vite `root: "./src"`. ZMP CLI khởi chạy dev server với project root; cấu hình khác sẽ khiến iframe app trả 404. Build output chuẩn là `www/` tại root.
- **Luồng xác thực**: Khi app mount, `AuthInitializer` gọi `/auth/me`; `AuthGuard` chỉ render private route sau khi khởi tạo xong và chuyển người dùng chưa đăng nhập tới `/login`. Cookie HTTP-only là cơ chế xác thực ưu tiên.
......
{
"app": {
"title": "FinWise - Sổ tay chi tiêu & Báo cáo tài chính",
"title": "FinWise",
"textColor": {
"light": "black",
"dark": "white"
......
import { useEffect, useState } from "react";
import { Text } from "zmp-ui";
import { useI18n } from "@/i18n";
function Clock() {
const { intlLocale } = useI18n();
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = now.toLocaleString("vi-VN", {
const formattedTime = now.toLocaleString(intlLocale, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
......@@ -21,7 +23,7 @@ function Clock() {
updateClock();
const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId);
}, []);
}, [intlLocale]);
return <Text className="font-mono">{time}</Text>;
}
......
import React from "react";
import { useI18n } from "@/i18n";
export const LanguageSwitcher: React.FC = () => {
const { locale, t, toggleLocale } = useI18n();
return (
<button
type="button"
aria-label={t("language.switchTo")}
title={t("language.switchTo")}
onClick={toggleLocale}
className="inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface px-2 font-baloo text-xs font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
>
{locale === "vi" ? "VI" : "EN"}
</button>
);
};
......@@ -14,6 +14,8 @@ import { AuthGuard } from "@/components/shared/AuthGuard";
import { DocumentTitle } from "@/components/shared/DocumentTitle";
import { ThemeControl } from "@/components/shared/ThemeControl";
import { applyTheme, useThemeStore } from "@/stores/theme-store";
import { I18nProvider } from "@/i18n";
import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher";
import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide";
......@@ -59,10 +61,16 @@ const Layout = () => {
}, [theme]);
return (
<I18nProvider>
<App theme={theme}>
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<div
className="finwise-header-controls fixed z-[1001] flex items-center gap-2"
>
<ThemeControl />
<LanguageSwitcher />
</div>
<ZMPRouter>
<DocumentTitle />
<AuthInitializer>
......@@ -85,6 +93,7 @@ const Layout = () => {
</SnackbarProvider>
</QueryClientProvider>
</App>
</I18nProvider>
);
};
export default Layout;
import React, { useEffect } from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
import { useI18n } from "@/i18n";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const navigate = useNavigate();
const { t } = useI18n();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
......@@ -16,7 +18,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
return (
<div className="page flex flex-col items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-clay-primary border-t-transparent rounded-full animate-spin shadow-clay-raised"></div>
<p className="clay-caption mt-4 animate-pulse text-clay-primary">Đang tải cấu hình...</p>
<p className="clay-caption mt-4 animate-pulse text-clay-primary">{t("common.loading")}</p>
</div>
);
}
......
import React, { useEffect } from "react";
import { useLocation } from "zmp-ui";
import { useI18n } from "@/i18n";
const APP_NAME = "FinWise";
const DEFAULT_TITLE = `${APP_NAME} | Sổ tay Chi tiêu & Báo cáo Tài chính`;
const PAGE_TITLES: ReadonlyArray<{
matches: (pathname: string) => boolean;
title: string;
key: string;
}> = [
{ matches: (pathname) => pathname === "/", title: "Trang chủ" },
{ matches: (pathname) => pathname === "/login", title: "Đăng nhập" },
{ matches: (pathname) => pathname === "/register", title: "Đăng ký" },
{ matches: (pathname) => pathname === "/forgot-password", title: "Quên mật khẩu" },
{ matches: (pathname) => pathname === "/reset-password", title: "Đặt lại mật khẩu" },
{ matches: (pathname) => pathname === "/profile", title: "Tài khoản" },
{ matches: (pathname) => pathname === "/wallets", title: "Ví của tôi" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), title: "Chi tiết ví" },
{ matches: (pathname) => pathname === "/style-guide", title: "Style Guide" },
{ matches: (pathname) => pathname === "/", key: "document.home" },
{ matches: (pathname) => pathname === "/login", key: "document.login" },
{ matches: (pathname) => pathname === "/register", key: "document.register" },
{ matches: (pathname) => pathname === "/forgot-password", key: "document.forgotPassword" },
{ matches: (pathname) => pathname === "/reset-password", key: "document.resetPassword" },
{ matches: (pathname) => pathname === "/profile", key: "document.profile" },
{ matches: (pathname) => pathname === "/wallets", key: "document.wallets" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
function getDocumentTitle(pathname: string): string {
const pageTitle = PAGE_TITLES.find((page) => page.matches(pathname))?.title;
return pageTitle ? `${pageTitle} | ${APP_NAME}` : DEFAULT_TITLE;
}
export const DocumentTitle: React.FC = () => {
const { pathname } = useLocation();
const { t } = useI18n();
useEffect(() => {
document.title = getDocumentTitle(pathname);
}, [pathname]);
const key = PAGE_TITLES.find((page) => page.matches(pathname))?.key;
document.title = `${key ? t(key) : t("document.default")} | ${APP_NAME}`;
}, [pathname, t]);
return null;
};
......@@ -5,15 +5,5 @@ import { useThemeStore } from "@/stores/theme-store";
export const ThemeControl: React.FC = () => {
const { theme, setTheme } = useThemeStore();
return (
<div
className="fixed z-[1001] flex items-center"
style={{
top: "calc(var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px)",
right: "calc(env(safe-area-inset-right, 0px) + 96px)",
}}
>
<ThemeToggle theme={theme} onChange={setTheme} variant="compact" />
</div>
);
return <ThemeToggle theme={theme} onChange={setTheme} variant="compact" />;
};
......@@ -5,6 +5,7 @@ import { ChevronRightIcon } from "@/components/ui/icons";
import { formatWalletBalance } from "@/lib/wallet-format";
import { Wallet } from "@/types/wallet";
import { WalletArtwork } from "./WalletArtwork";
import { useI18n } from "@/i18n";
interface WalletCardProps {
wallet: Wallet;
......@@ -18,7 +19,10 @@ export const WalletCard: React.FC<WalletCardProps> = ({
onClick,
onSetDefault,
isSettingDefault = false,
}) => (
}) => {
const { intlLocale, t } = useI18n();
return (
<Card
hoverable
className={`p-4 ${wallet.isArchived ? "opacity-75" : ""}`}
......@@ -36,11 +40,11 @@ export const WalletCard: React.FC<WalletCardProps> = ({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3>
{wallet.isDefault && <Badge type="primary">Mặc định</Badge>}
{wallet.isArchived && <Badge type="warning">Đã lưu trữ</Badge>}
{wallet.isDefault && <Badge type="primary">{t("common.default")}</Badge>}
{wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div>
<p className="font-baloo text-xl font-bold text-clay-primary-dark truncate">
{formatWalletBalance(wallet.balance, wallet.currency)}
{formatWalletBalance(wallet.balance, wallet.currency, intlLocale)}
</p>
{wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>}
</div>
......@@ -57,8 +61,9 @@ export const WalletCard: React.FC<WalletCardProps> = ({
onSetDefault();
}}
>
{isSettingDefault ? "Đang cập nhật..." : "Đặt làm ví mặc định"}
{isSettingDefault ? t("common.updating") : t("wallet.setDefault")}
</button>
)}
</Card>
);
);
};
import React from "react";
import { useI18n } from "@/i18n";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg";
......@@ -10,12 +11,13 @@ export const Avatar: React.FC<AvatarProps> = ({
size = "md",
className = "",
src,
alt = "User Avatar",
alt,
positionX = 50,
positionY = 50,
style,
...props
}) => {
const { t } = useI18n();
const sizeStyles = {
sm: "w-10 h-10 border-2",
md: "w-14 h-14 border-[3px]",
......@@ -34,7 +36,7 @@ export const Avatar: React.FC<AvatarProps> = ({
>
<img
src={src || defaultAvatar}
alt={alt}
alt={alt || t("accessibility.userAvatar")}
className="w-full h-full object-cover rounded-full"
style={{
...style,
......
import React, { useEffect } from "react";
import { CloseIcon } from "./icons";
import { Button } from "./Button";
import { useI18n } from "@/i18n";
export interface ModalProps {
isOpen: boolean;
......@@ -17,6 +18,7 @@ export const Modal: React.FC<ModalProps> = ({
children,
footer,
}) => {
const { t } = useI18n();
// Prevent background scrolling when open
useEffect(() => {
if (isOpen) {
......@@ -52,7 +54,9 @@ export const Modal: React.FC<ModalProps> = ({
<div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3>
<button
type="button"
onClick={onClose}
aria-label={t("accessibility.closeModal")}
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]"
>
<CloseIcon size={16} />
......
import React from "react";
import { ThemeMode } from "@/stores/theme-store";
import { useI18n } from "@/i18n";
export interface ThemeToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
theme: ThemeMode;
......@@ -28,8 +29,9 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
onClick,
...props
}) => {
const { t } = useI18n();
const isDark = theme === "dark";
const nextThemeLabel = isDark ? "giao diện sáng" : "giao diện tối";
const nextThemeLabel = t(isDark ? "theme.switchToLight" : "theme.switchToDark");
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(isDark ? "light" : "dark");
onClick?.(event);
......@@ -41,8 +43,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
type="button"
role="switch"
aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`}
title={`Chuyển sang ${nextThemeLabel}`}
aria-label={nextThemeLabel}
title={nextThemeLabel}
{...props}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
onClick={handleClick}
......@@ -57,8 +59,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
type="button"
role="switch"
aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`}
title={`Chuyển sang ${nextThemeLabel}`}
aria-label={nextThemeLabel}
title={nextThemeLabel}
className={`relative inline-flex h-8 w-14 flex-none items-center rounded-full border border-clay-border bg-clay-surface p-1 text-clay-text-muted shadow-clay-pressed transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
{...props}
onClick={handleClick}
......
......@@ -36,10 +36,17 @@
--color-clay-badge-info-bg: 239 246 255;
--color-clay-badge-info-text: 37 99 235;
--color-clay-badge-info-border: 219 234 254;
--shadow-clay-raised: 8px 8px 16px rgb(163 150 208 / 0.45), -6px -6px 14px rgb(255 255 255 / 0.85);
--shadow-clay-hover: 10px 10px 20px rgb(163 150 208 / 0.45), -8px -8px 16px rgb(255 255 255 / 0.85);
--shadow-clay-pressed: inset 4px 4px 8px rgb(163 150 208 / 0.45), inset -4px -4px 8px rgb(255 255 255 / 0.85);
--shadow-clay-modal: 15px 15px 30px rgb(163 150 208 / 0.55), -10px -10px 20px rgb(255 255 255 / 0.9);
--shadow-clay-raised:
8px 8px 16px rgb(163 150 208 / 0.45), -6px -6px 14px rgb(255 255 255 / 0.85);
--shadow-clay-hover:
10px 10px 20px rgb(163 150 208 / 0.45),
-8px -8px 16px rgb(255 255 255 / 0.85);
--shadow-clay-pressed:
inset 4px 4px 8px rgb(163 150 208 / 0.45),
inset -4px -4px 8px rgb(255 255 255 / 0.85);
--shadow-clay-modal:
15px 15px 30px rgb(163 150 208 / 0.55),
-10px -10px 20px rgb(255 255 255 / 0.9);
--shadow-clay-progress: inset -2px -2px 4px rgb(45 42 69 / 0.15);
}
......@@ -80,10 +87,15 @@
--color-clay-badge-info-bg: 31 58 91;
--color-clay-badge-info-text: 173 211 255;
--color-clay-badge-info-border: 53 91 137;
--shadow-clay-raised: 8px 8px 16px rgb(5 4 14 / 0.68), -6px -6px 14px rgb(78 72 105 / 0.35);
--shadow-clay-hover: 10px 10px 20px rgb(5 4 14 / 0.75), -8px -8px 16px rgb(83 76 112 / 0.4);
--shadow-clay-pressed: inset 4px 4px 8px rgb(5 4 14 / 0.72), inset -4px -4px 8px rgb(82 75 111 / 0.34);
--shadow-clay-modal: 15px 15px 30px rgb(4 3 11 / 0.78), -10px -10px 20px rgb(82 75 111 / 0.32);
--shadow-clay-raised:
8px 8px 16px rgb(5 4 14 / 0.68), -6px -6px 14px rgb(78 72 105 / 0.35);
--shadow-clay-hover:
10px 10px 20px rgb(5 4 14 / 0.75), -8px -8px 16px rgb(83 76 112 / 0.4);
--shadow-clay-pressed:
inset 4px 4px 8px rgb(5 4 14 / 0.72),
inset -4px -4px 8px rgb(82 75 111 / 0.34);
--shadow-clay-modal:
15px 15px 30px rgb(4 3 11 / 0.78), -10px -10px 20px rgb(82 75 111 / 0.32);
--shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45);
}
......@@ -95,24 +107,31 @@ body,
}
body {
font-family: 'Nunito', sans-serif;
font-family: "Nunito", sans-serif;
margin: 0;
color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased;
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
transition:
background-color 200ms ease-in-out,
color 200ms ease-in-out;
}
/* Custom style override for ZMP Page containers */
.page {
padding: calc(var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px) 16px 96px;
padding: calc(
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px
)
16px 96px;
background-color: rgb(var(--color-clay-bg));
min-height: 100vh;
color: rgb(var(--color-clay-text));
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
transition:
background-color 200ms ease-in-out,
color 200ms ease-in-out;
}
.clay-title-h1 {
font-family: 'Baloo 2', sans-serif;
font-family: "Baloo 2", sans-serif;
font-weight: 700;
font-size: 32px;
line-height: 1.2;
......@@ -120,7 +139,7 @@ body {
}
.clay-title-h2 {
font-family: 'Baloo 2', sans-serif;
font-family: "Baloo 2", sans-serif;
font-weight: 600;
font-size: 24px;
line-height: 1.3;
......@@ -128,7 +147,7 @@ body {
}
.clay-title-h3 {
font-family: 'Baloo 2', sans-serif;
font-family: "Baloo 2", sans-serif;
font-weight: 600;
font-size: 18px;
line-height: 1.4;
......@@ -136,7 +155,7 @@ body {
}
.clay-body {
font-family: 'Nunito', sans-serif;
font-family: "Nunito", sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.5;
......@@ -144,7 +163,7 @@ body {
}
.clay-caption {
font-family: 'Nunito', sans-serif;
font-family: "Nunito", sans-serif;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
......@@ -172,10 +191,29 @@ body {
.zaui-page {
background-color: rgb(var(--color-clay-bg));
color: rgb(var(--color-clay-text));
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
transition:
background-color 200ms ease-in-out,
color 200ms ease-in-out;
}
.zaui-header .zaui-header-title,
.zaui-header .zaui-header-back-icon {
color: rgb(var(--color-clay-text));
}
/* Keep app controls clear of Zalo's native `right-buttons` area. */
.finwise-header-controls {
top: calc(
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px
);
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 80px
);
}
/* Reserve space for native right-buttons (96px), both 36px controls, and gaps. */
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
);
}
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import en from "./locales/en.json";
import vi from "./locales/vi.json";
export type TranslationParams = Record<string, string | number>;
export type TranslationFunction = (key: string, params?: TranslationParams) => string;
const LOCALE_STORAGE_KEY = "finwise.locale";
const resources = {
vi: { messages: vi, intlLocale: "vi-VN" },
en: { messages: en, intlLocale: "en-US" },
} as const;
export type Locale = keyof typeof resources;
const supportedLocales = Object.keys(resources) as Locale[];
interface I18nContextValue {
locale: Locale;
intlLocale: string;
setLocale: (locale: Locale) => void;
toggleLocale: () => void;
t: TranslationFunction;
formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (value: number, currency: string) => string;
formatDate: (value: string | number | Date, options?: Intl.DateTimeFormatOptions) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function readStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
return stored && stored in resources ? stored as Locale : "vi";
} catch {
return "vi";
}
}
function resolveTranslation(locale: Locale, key: string): string {
const value = key.split(".").reduce<unknown>((current, segment) => {
if (current && typeof current === "object" && segment in current) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, resources[locale].messages);
if (typeof value === "string") return value;
if (locale !== "vi") return resolveTranslation("vi", key);
return key;
}
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [locale, setLocaleState] = useState<Locale>(readStoredLocale);
const intlLocale = resources[locale].intlLocale;
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
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.
}
}, []);
const toggleLocale = useCallback(() => {
const currentIndex = supportedLocales.indexOf(locale);
setLocale(supportedLocales[(currentIndex + 1) % supportedLocales.length]);
}, [locale, setLocale]);
const t = useCallback<TranslationFunction>((key, params) => {
const template = resolveTranslation(locale, key);
if (!params) return template;
return template.replace(/{{(\w+)}}/g, (match, name: string) =>
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
);
}, [locale]);
const formatNumber = useCallback((value: number, options?: Intl.NumberFormatOptions) =>
new Intl.NumberFormat(intlLocale, options).format(value), [intlLocale]);
const formatCurrency = useCallback((value: number, currency: string) => {
try {
return new Intl.NumberFormat(intlLocale, {
style: "currency",
currency,
maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(value);
} catch {
return `${new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 2 }).format(value)} ${currency}`;
}
}, [intlLocale]);
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(intlLocale, options).format(new Date(value)), [intlLocale]);
const contextValue = useMemo<I18nContextValue>(() => ({
locale,
intlLocale,
setLocale,
toggleLocale,
t,
formatNumber,
formatCurrency,
formatDate,
}), [formatCurrency, formatDate, formatNumber, intlLocale, locale, setLocale, t, toggleLocale]);
return React.createElement(I18nContext.Provider, { value: contextValue }, children);
};
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext);
if (!context) throw new Error("useI18n must be used within I18nProvider");
return context;
}
{
"common": {
"appName": "FinWise",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"delete": "Delete",
"retry": "Try again",
"loading": "Loading...",
"processing": "Processing...",
"updating": "Updating...",
"previous": "Previous",
"next": "Next",
"user": "User",
"active": "Active",
"archived": "Archived",
"default": "Default",
"results": "{{count}} results",
"pageOf": "{{page}} / {{total}}"
},
"language": {
"label": "Language",
"switchTo": "Switch to Vietnamese",
"current": "English",
"short": "EN"
},
"theme": {
"section": "Appearance",
"light": "Light mode",
"dark": "Dark mode",
"saved": "preference saved on this device",
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode"
},
"document": {
"default": "Expense Journal & Financial Reports",
"home": "Home",
"login": "Sign in",
"register": "Sign up",
"forgotPassword": "Forgot password",
"resetPassword": "Reset password",
"profile": "Account",
"wallets": "My Wallets",
"walletDetail": "Wallet Details",
"styleGuide": "Style Guide"
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"gmailOnly": "Only @gmail.com addresses are accepted",
"fullNameMin": "Full name must contain at least 2 characters",
"passwordMin": "Password must contain at least 8 characters",
"newPasswordMin": "New password must contain at least 8 characters",
"passwordStrength": "Password must include at least 1 uppercase letter, 1 lowercase letter, and 1 number",
"confirmPassword": "Please confirm your password",
"confirmNewPassword": "Please confirm your new password",
"passwordMismatch": "Passwords do not match",
"currentPasswordRequired": "Please enter your current password",
"resetTokenInvalid": "The reset code is invalid (it must be a UUID)",
"phoneInvalid": "Enter a valid phone number (for example: 0912345678)",
"avatarUrlInvalid": "Enter a valid avatar URL",
"walletNameRequired": "Enter a wallet name",
"walletNameMax": "Wallet name cannot exceed 100 characters",
"balanceInvalid": "Balance supports up to 16 digits and 2 decimal places",
"currencyLength": "Currency code must contain exactly 3 letters",
"currencyLetters": "Currency code can contain letters only",
"descriptionMax": "Description cannot exceed 500 characters"
},
"auth": {
"email": "Email",
"password": "Password",
"login": {
"header": "Sign In",
"title": "Welcome!",
"subtitle": "Sign in to your FinWise account to get started",
"rememberEmail": "Remember email",
"forgotPassword": "Forgot password?",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submitting": "Signing in...",
"submit": "Sign In",
"noAccount": "Don't have an account?",
"registerNow": "Sign up now",
"success": "Signed in successfully! 🎉",
"failed": "Sign-in failed",
"failedDetail": "Sign-in failed. Please check your details and try again."
},
"register": {
"header": "Sign Up",
"title": "Create Account",
"subtitle": "Start your journey toward smarter financial management",
"fullName": "Full name",
"fullNamePlaceholder": "John Doe",
"gmailLabel": "Email (@gmail.com only)",
"confirmPassword": "Confirm password",
"submitting": "Creating account...",
"submit": "Sign Up",
"hasAccount": "Already have an account?",
"login": "Sign in",
"success": "Account created! Check your email to activate it.",
"failed": "Sign-up failed",
"failedDetail": "Sign-up failed. Please try again.",
"checkEmail": "Check Your Email",
"checkEmailDescription": "We sent an activation link to your email. Open the link to activate your account before signing in.",
"backToLogin": "Back to Sign In"
},
"forgot": {
"header": "Forgot Password",
"title": "Forgot Password?",
"subtitle": "Enter your email to start recovering your account",
"linkedEmail": "Account email",
"submitting": "Sending...",
"submit": "Send Request",
"rememberPassword": "Remember your password?",
"login": "Sign in",
"successToast": "A password recovery link was sent to your email! ✉️",
"failed": "Could not send request",
"failedDetail": "Could not send the request. Please try again.",
"sentTitle": "Email Sent",
"sentDescription": "We sent password reset instructions to your email. Check your inbox and spam folder.",
"enterResetCode": "Enter reset code"
},
"reset": {
"header": "Reset Password",
"title": "Reset Password",
"subtitle": "Enter the reset code from your email and choose a new password",
"token": "Reset code (UUID token)",
"newPassword": "New password",
"confirmNewPassword": "Confirm new password",
"submit": "Change Password",
"back": "Back to",
"login": "Sign in",
"success": "Password reset! You can sign in now.",
"failed": "Password reset failed",
"expired": "The token is invalid or has expired."
}
},
"home": {
"header": "FinWise Mini App",
"greeting": "Hello, {{name}}!",
"subtitle": "Expense Journal & Financial Reports",
"account": "Account:",
"authTitle": "System authentication",
"authDescription": "You are signed in. Upcoming API requests will be authenticated automatically using secure cookies and JWT.",
"wallets": "Manage wallets",
"profile": "My profile",
"styleGuide": "Explore Style Guide"
},
"profile": {
"header": "Account",
"editAvatar": "Edit profile picture",
"notSet": "Not set",
"tabs": { "info": "Profile", "password": "Password", "devices": "Devices" },
"personalInfo": "Personal information",
"fullName": "Full name",
"fullNamePlaceholder": "Enter your full name...",
"phone": "Phone number",
"phonePlaceholder": "Enter your phone number...",
"update": "Update Profile",
"updateSuccess": "Profile updated successfully! 🎉",
"updateFailed": "Update failed. Please try again.",
"changePassword": "Change password",
"currentPassword": "Current password",
"currentPasswordPlaceholder": "Enter your current password...",
"newPassword": "New password",
"newPasswordPlaceholder": "New password, at least 8 characters...",
"confirmNewPassword": "Confirm new password",
"confirmNewPasswordPlaceholder": "Enter the new password again...",
"passwordSubmit": "Change Password",
"passwordSuccess": "Password changed successfully! 🔑",
"passwordFailed": "Could not change password.",
"sessionManagement": "Manage sign-in sessions",
"sessionDescription": "Protect your account by signing out of all other devices and browsers you previously used.",
"logoutOthers": "Sign out other devices",
"logoutOthersSuccess": "Signed out of all other devices! 📱",
"logoutOthersFailed": "Could not sign out other devices.",
"activeDevices": "Active devices",
"sessionsLoading": "Loading session list",
"sessionsFailed": "Could not load sessions. Please try again.",
"unnamedDevice": "Unnamed device",
"thisDevice": "This device",
"activeSince": "Active since: {{date}}",
"ipAddress": "IP: {{address}}",
"logout": "Sign Out",
"logoutDevice": "Sign out",
"logoutSuccess": "Signed out. See you next time! 👋",
"revokeSuccess": "Session closed successfully.",
"revokeFailed": "Could not close the session.",
"noSessions": "There are no other sign-in sessions.",
"avatarSelectRequired": "Choose a profile picture before saving.",
"avatarSaveFailed": "Could not save the profile picture to your profile.",
"avatarSaveSuccess": "Picture and crop position saved!",
"avatarUploadFailed": "Could not upload the profile picture.",
"avatarDeleteFailed": "Could not delete the profile picture.",
"avatarDeleteSuccess": "Profile picture deleted.",
"avatarTypeInvalid": "Profile picture must be JPEG, PNG, or WebP.",
"avatarSizeInvalid": "Profile picture cannot exceed 5 MB.",
"avatarReadFailed": "Could not read the selected image. Try another one."
},
"avatarEditor": {
"title": "Edit Profile Picture",
"delete": "Delete picture",
"save": "Save picture",
"dragLabel": "Drag to select the profile picture area",
"previewAlt": "Profile picture preview",
"selectToStart": "Choose an image to get started",
"instruction": "Drag the image in the circle or use the sliders to choose the visible area.",
"horizontal": "Horizontal",
"vertical": "Vertical",
"locked": "Locked",
"horizontalLocked": "This image ratio has no extra horizontal area to adjust.",
"verticalLocked": "This image ratio has no extra vertical area to adjust.",
"moveLeft": "Move image left",
"moveRight": "Move image right",
"moveUp": "Move image up",
"moveDown": "Move image down",
"chooseAnother": "Choose another image",
"choose": "Choose image",
"fileHint": "JPEG, PNG, WebP · up to 5 MB"
},
"wallet": {
"header": "My Wallets",
"detailHeader": "Wallet Details",
"summary": "Total balance by currency",
"noBalance": "No balance yet",
"activeCount": "{{count}} active wallets",
"list": "Wallet list",
"syncing": "Syncing...",
"create": "Create wallet",
"createNew": "Create new wallet",
"searchLabel": "Search wallets",
"searchPlaceholder": "Search by name, description, or currency...",
"sortLabel": "Sort wallets",
"includeArchived": "Archived",
"loadingList": "Loading wallet list",
"loadFailed": "Could not load wallets",
"connectionFailed": "Check your connection and try again.",
"notFound": "No matching wallets found",
"empty": "No wallets yet",
"first": "Start with your first wallet",
"searchHint": "Try another keyword or include archived wallets.",
"emptyHint": "Create a wallet to track balances and manage your finances.",
"paginationLabel": "Wallet pagination",
"createSuccess": "Wallet created successfully.",
"createFailed": "Could not create the wallet. Please try again.",
"defaultUpdated": "Default wallet updated.",
"defaultSet": "Set as default wallet.",
"defaultFailed": "Could not set the default wallet.",
"setDefault": "Set as default wallet",
"detailLoadFailed": "Could not load wallet details",
"missing": "The wallet does not exist or the connection was interrupted.",
"listButton": "Wallet list",
"defaultWallet": "Default wallet",
"info": "Wallet information",
"status": "Status",
"createdAt": "Created",
"updatedAt": "Last updated",
"management": "Manage wallet",
"edit": "Edit information",
"archive": "Archive wallet",
"archiveDefaultHint": "Set another wallet as default before archiving this one.",
"restore": "Restore wallet",
"restoring": "Restoring...",
"restoreHint": "If there is no active default wallet, this wallet will become the default after restoration.",
"updateSuccess": "Wallet changes saved.",
"updateFailed": "Could not update the wallet.",
"archiveSuccess": "Wallet archived; transaction history remains available.",
"archiveFailed": "Could not archive the wallet.",
"restoreSuccess": "Wallet restored successfully.",
"restoreFailed": "Could not restore the wallet.",
"archiveTitle": "Archive wallet?",
"archiving": "Archiving...",
"archiveConfirm": "Confirm archive",
"archiveDescription": "The wallet can no longer be used for new transactions, but its history and balance will be preserved. You can restore it at any time.",
"form": {
"editTitle": "Edit wallet",
"createTitle": "Create wallet",
"saveChanges": "Save changes",
"preview": "Wallet preview",
"previewHint": "Choose an identifying icon and color",
"name": "Wallet name *",
"namePlaceholder": "For example: Cash",
"balance": "Balance *",
"currency": "Currency *",
"icon": "Icon",
"color": "Wallet color",
"chooseColor": "Choose color {{color}}",
"description": "Description",
"descriptionPlaceholder": "What this wallet is used for...",
"defaultTitle": "Set as default wallet",
"defaultHint": "The first wallet always becomes the default automatically."
},
"icons": { "wallet": "Wallet", "cash": "Cash", "bank": "Bank", "card": "Card", "savings": "Savings" },
"sort": { "createdDesc": "Recently created", "updatedDesc": "Recently updated", "nameAsc": "Name A → Z", "nameDesc": "Name Z → A", "balanceDesc": "Highest balance", "balanceAsc": "Lowest balance" }
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Claymorphism Theme System",
"subtitle": "Adaptive light/dark design tokens for FinWise's soft clay interface",
"tabs": { "overview": "Overview", "income": "Income", "expense": "Expense" },
"currencies": { "vnd": "Vietnamese Dong (VND)", "usd": "US Dollar (USD)", "eur": "Euro (EUR)" },
"typographyColors": "1. Typography & Colors",
"typographyScale": "Typography Scale",
"typeH1": "clay-title-h1 (32px / Baloo 2):",
"typeH2": "clay-title-h2 (24px / Baloo 2):",
"typeH3": "clay-title-h3 (18px / Baloo 2):",
"typeBody": "clay-body (16px / Nunito):",
"typeCaption": "clay-caption (13px / Nunito):",
"sampleWelcome": "Welcome! ₫5,000,000",
"sampleWallet": "My wallets",
"sampleCategory": "Food category",
"sampleBody": "This is regular body content designed to be clear and easy to read.",
"sampleCaption": "Transaction completed today at 12:30 PM",
"semanticColors": "Semantic Colors",
"semanticHint": "Use the controls in the top-right corner to inspect the same semantic token in both themes.",
"appBackground": "App background",
"cardInput": "Card / Input",
"primary": "Primary",
"primaryStrong": "Primary strong",
"incomeSuccess": "Income / Success",
"expenseError": "Expense / Error",
"warning": "Warning",
"info": "Info",
"primaryVariant": "Primary:",
"secondaryVariant": "Secondary:",
"ghostVariant": "Ghost:",
"buttons": "2. Buttons",
"buttonHint": "Press the buttons to experience the Claymorphism pressed effect and motion.",
"primaryButton": "Primary Button",
"pillButton": "Pill Button",
"disabled": "Disabled",
"secondaryButton": "Secondary Button",
"pillShape": "Pill Shape",
"skip": "Skip",
"fullWidth": "Full-width Button",
"cards": "3. Cards",
"raisedCard": "Raised Card (Default)",
"raisedCardHint": "The card floats above the smooth background.",
"hoverableCard": "Hoverable / Clickable Card",
"hoverableCardHint": "The card rises further when hovered or tapped.",
"inputs": "4. Inputs & Selects",
"inputHint": "Input fields look pressed into the clay surface.",
"amount": "Transaction amount (VND)",
"amountPlaceholder": "Enter an amount...",
"note": "Expense note",
"notePlaceholder": "For example: Office lunch...",
"noteError": "The note cannot be empty",
"currency": "Select currency",
"linkedAccount": "Linked account (Locked)",
"badges": "5. Badges & Icon Wrappers",
"badgeTypes": "Pastel status badges",
"system": "System",
"income": "Income (+)",
"expense": "Expense (-)",
"overLimit": "Over limit",
"news": "News",
"iconWrappers": "Category icon wrappers",
"smallIncome": "Small (40px) - Income",
"mediumExpense": "Medium (48px) - Expense",
"largePrimary": "Large (64px) - Primary",
"avatar": "6. Avatar",
"roundedAvatar": "Rounded avatar",
"avatarHint": "3px white border & soft shadow",
"tabsSection": "7. Tabs",
"tabsHint": "Tabs sit in a recessed track; the selected tab rises above it.",
"showing": "Currently showing:",
"progress": "8. Progress Bar",
"progressHint": "Use for budgets and saving goals. Adjust the slider to preview updates.",
"adjustProgress": "Adjust progress:",
"defaultBudget": "Primary (Default budget):",
"saving": "Income (Savings):",
"spent": "Expense (Spent):",
"nearLimit": "Warning (Near limit):",
"modal": "9. Modal & Sheet",
"openModal": "Open Demo Modal",
"addTransaction": "Add Transaction",
"saveTransaction": "Save Transaction",
"modalHint": "The modal uses a blurred backdrop, generous rounded corners, and a prominent shadow.",
"transactionName": "Transaction name",
"namePlaceholder": "Enter a name...",
"icons": "10. 3D SVG Icons",
"home": "Home",
"wallet": "Wallet",
"transaction": "Transaction",
"transfer": "Transfer",
"budget": "Budget",
"savings": "Savings",
"report": "Reports",
"assistant": "AI Assistant",
"notification": "Notifications",
"account": "Account",
"add": "Add",
"food": "Food"
},
"accessibility": {
"closeModal": "Close dialog",
"userAvatar": "User profile picture"
}
}
{
"common": {
"appName": "FinWise",
"cancel": "Hủy",
"save": "Lưu",
"saving": "Đang lưu...",
"delete": "Xóa",
"retry": "Thử lại",
"loading": "Đang tải...",
"processing": "Đang xử lý...",
"updating": "Đang cập nhật...",
"previous": "Trước",
"next": "Sau",
"user": "Người dùng",
"active": "Đang hoạt động",
"archived": "Đã lưu trữ",
"default": "Mặc định",
"results": "{{count}} kết quả",
"pageOf": "{{page}} / {{total}}"
},
"language": {
"label": "Ngôn ngữ",
"switchTo": "Chuyển sang Tiếng Anh",
"current": "Tiếng Việt",
"short": "VI"
},
"theme": {
"section": "Giao diện",
"light": "Chế độ sáng",
"dark": "Chế độ tối",
"saved": "lựa chọn được lưu trên thiết bị",
"switchToLight": "Chuyển sang giao diện sáng",
"switchToDark": "Chuyển sang giao diện tối"
},
"document": {
"default": "Sổ tay Chi tiêu & Báo cáo Tài chính",
"home": "Trang chủ",
"login": "Đăng nhập",
"register": "Đăng ký",
"forgotPassword": "Quên mật khẩu",
"resetPassword": "Đặt lại mật khẩu",
"profile": "Tài khoản",
"wallets": "Ví của tôi",
"walletDetail": "Chi tiết ví",
"styleGuide": "Style Guide"
},
"validation": {
"emailRequired": "Email không được để trống",
"emailInvalid": "Email không hợp lệ",
"gmailOnly": "Chỉ chấp nhận địa chỉ email @gmail.com",
"fullNameMin": "Họ và tên phải từ 2 ký tự trở lên",
"passwordMin": "Mật khẩu phải từ 8 ký tự trở lên",
"newPasswordMin": "Mật khẩu mới phải từ 8 ký tự trở lên",
"passwordStrength": "Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số",
"confirmPassword": "Vui lòng xác nhận mật khẩu",
"confirmNewPassword": "Vui lòng xác nhận mật khẩu mới",
"passwordMismatch": "Mật khẩu xác nhận không khớp",
"currentPasswordRequired": "Vui lòng nhập mật khẩu hiện tại",
"resetTokenInvalid": "Mã khôi phục không hợp lệ (phải ở định dạng UUID)",
"phoneInvalid": "Số điện thoại không hợp lệ (ví dụ: 0912345678)",
"avatarUrlInvalid": "Đường dẫn ảnh đại diện không hợp lệ",
"walletNameRequired": "Vui lòng nhập tên ví",
"walletNameMax": "Tên ví tối đa 100 ký tự",
"balanceInvalid": "Số dư có tối đa 16 chữ số và 2 số thập phân",
"currencyLength": "Mã tiền tệ gồm đúng 3 chữ cái",
"currencyLetters": "Mã tiền tệ chỉ gồm chữ cái",
"descriptionMax": "Mô tả tối đa 500 ký tự"
},
"auth": {
"email": "Email",
"password": "Mật khẩu",
"login": {
"header": "Đăng Nhập",
"title": "Chào Mừng!",
"subtitle": "Đăng nhập tài khoản FinWise của bạn để bắt đầu",
"rememberEmail": "Ghi nhớ email",
"forgotPassword": "Quên mật khẩu?",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"submitting": "Đang đăng nhập...",
"submit": "Đăng Nhập",
"noAccount": "Chưa có tài khoản?",
"registerNow": "Đăng ký ngay",
"success": "Đăng nhập thành công! 🎉",
"failed": "Đăng nhập thất bại",
"failedDetail": "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."
},
"register": {
"header": "Đăng Ký",
"title": "Tạo Tài Khoản",
"subtitle": "Khởi đầu hành trình quản lý tài chính thông minh",
"fullName": "Họ và tên",
"fullNamePlaceholder": "Nguyễn Văn A",
"gmailLabel": "Email (chỉ nhận @gmail.com)",
"confirmPassword": "Xác nhận mật khẩu",
"submitting": "Đang đăng ký...",
"submit": "Đăng Ký",
"hasAccount": "Đã có tài khoản?",
"login": "Đăng nhập",
"success": "Đăng ký thành công! Vui lòng kiểm tra email để kích hoạt.",
"failed": "Đăng ký thất bại",
"failedDetail": "Đăng ký thất bại, vui lòng thử lại.",
"checkEmail": "Kiểm tra Email của bạn",
"checkEmailDescription": "Chúng tôi đã gửi một liên kết kích hoạt đến email của bạn. Vui lòng nhấp vào liên kết để kích hoạt tài khoản trước khi đăng nhập.",
"backToLogin": "Quay lại Đăng Nhập"
},
"forgot": {
"header": "Quên Mật Khẩu",
"title": "Quên Mật Khẩu?",
"subtitle": "Đừng lo lắng, hãy nhập email của bạn để bắt đầu khôi phục",
"linkedEmail": "Email liên kết",
"submitting": "Đang gửi...",
"submit": "Gửi Yêu Cầu",
"rememberPassword": "Nhớ mật khẩu?",
"login": "Đăng nhập",
"successToast": "Liên kết khôi phục mật khẩu đã được gửi đến email của bạn! ✉️",
"failed": "Gửi yêu cầu thất bại",
"failedDetail": "Gửi yêu cầu thất bại, vui lòng thử lại.",
"sentTitle": "Đã gửi Email thành công",
"sentDescription": "Chúng tôi đã gửi hướng dẫn đặt lại mật khẩu đến email của bạn. Vui lòng kiểm tra hộp thư đến (và cả hộp thư rác).",
"enterResetCode": "Nhập mã đặt lại mật khẩu"
},
"reset": {
"header": "Đặt Lại Mật Khẩu",
"title": "Đặt Lại Mật Khẩu",
"subtitle": "Nhập mã đặt lại nhận được trong email và mật khẩu mới của bạn",
"token": "Mã khôi phục (Token UUID)",
"newPassword": "Mật khẩu mới",
"confirmNewPassword": "Xác nhận mật khẩu mới",
"submit": "Xác Nhận Đổi Mật Khẩu",
"back": "Quay lại",
"login": "Đăng nhập",
"success": "Đặt lại mật khẩu thành công! Bạn có thể đăng nhập ngay.",
"failed": "Đặt lại mật khẩu thất bại",
"expired": "Mã token không hợp lệ hoặc đã hết hạn."
}
},
"home": {
"header": "FinWise Mini App",
"greeting": "Chào, {{name}}!",
"subtitle": "Sổ tay Chi tiêu & Báo cáo Tài chính",
"account": "Tài khoản:",
"authTitle": "Xác thực hệ thống",
"authDescription": "Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.",
"wallets": "Quản lý ví",
"profile": "Trang cá nhân của tôi",
"styleGuide": "Khám phá Style Guide"
},
"profile": {
"header": "Tài Khoản",
"editAvatar": "Chỉnh ảnh đại diện",
"notSet": "Chưa thiết lập",
"tabs": { "info": "Thông Tin", "password": "Mật Khẩu", "devices": "Thiết Bị" },
"personalInfo": "Thông tin cá nhân",
"fullName": "Họ và tên",
"fullNamePlaceholder": "Nhập họ và tên...",
"phone": "Số điện thoại",
"phonePlaceholder": "Nhập số điện thoại...",
"update": "Cập Nhật Thông Tin",
"updateSuccess": "Cập nhật thông tin cá nhân thành công! 🎉",
"updateFailed": "Cập nhật thất bại, vui lòng thử lại.",
"changePassword": "Đổi mật khẩu",
"currentPassword": "Mật khẩu hiện tại",
"currentPasswordPlaceholder": "Nhập mật khẩu hiện tại...",
"newPassword": "Mật khẩu mới",
"newPasswordPlaceholder": "Mật khẩu mới ít nhất 8 ký tự...",
"confirmNewPassword": "Xác nhận mật khẩu mới",
"confirmNewPasswordPlaceholder": "Nhập lại mật khẩu mới...",
"passwordSubmit": "Đổi Mật Khẩu",
"passwordSuccess": "Thay đổi mật khẩu thành công! 🔑",
"passwordFailed": "Thay đổi mật khẩu thất bại.",
"sessionManagement": "Quản lý phiên đăng nhập",
"sessionDescription": "Bảo vệ tài khoản bằng cách đăng xuất khỏi tất cả các thiết bị và trình duyệt khác mà bạn đã đăng nhập trước đó.",
"logoutOthers": "Đăng xuất các thiết bị khác",
"logoutOthersSuccess": "Đã đăng xuất khỏi tất cả các thiết bị khác thành công! 📱",
"logoutOthersFailed": "Không thể đăng xuất các thiết bị khác.",
"activeDevices": "Thiết bị đang hoạt động",
"sessionsLoading": "Đang tải danh sách phiên",
"sessionsFailed": "Không thể tải danh sách phiên. Vui lòng thử lại.",
"unnamedDevice": "Thiết bị không tên",
"thisDevice": "Thiết bị này",
"activeSince": "Hoạt động từ: {{date}}",
"ipAddress": "IP: {{address}}",
"logout": "Đăng Xuất",
"logoutDevice": "Đăng xuất",
"logoutSuccess": "Đăng xuất thành công. Hẹn gặp lại bạn! 👋",
"revokeSuccess": "Đã đóng phiên đăng nhập thành công.",
"revokeFailed": "Không thể đóng phiên đăng nhập.",
"noSessions": "Không có phiên đăng nhập nào khác.",
"avatarSelectRequired": "Vui lòng chọn ảnh đại diện trước khi lưu.",
"avatarSaveFailed": "Không thể lưu ảnh đại diện vào hồ sơ.",
"avatarSaveSuccess": "Đã lưu ảnh và vùng hiển thị!",
"avatarUploadFailed": "Tải ảnh đại diện thất bại.",
"avatarDeleteFailed": "Không thể xóa ảnh đại diện.",
"avatarDeleteSuccess": "Đã xóa ảnh đại diện.",
"avatarTypeInvalid": "Ảnh đại diện phải là JPEG, PNG hoặc WebP.",
"avatarSizeInvalid": "Ảnh đại diện không được vượt quá 5 MB.",
"avatarReadFailed": "Không thể đọc ảnh đã chọn. Vui lòng thử ảnh khác."
},
"avatarEditor": {
"title": "Chỉnh ảnh đại diện",
"delete": "Xóa ảnh",
"save": "Lưu ảnh",
"dragLabel": "Kéo để chọn vùng ảnh đại diện",
"previewAlt": "Xem trước ảnh đại diện",
"selectToStart": "Chọn một ảnh để bắt đầu",
"instruction": "Kéo ảnh trong khung tròn hoặc dùng thanh trượt để chọn vùng hiển thị.",
"horizontal": "Ngang",
"vertical": "Dọc",
"locked": "Đang khóa",
"horizontalLocked": "Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều ngang.",
"verticalLocked": "Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều dọc.",
"moveLeft": "Dịch ảnh sang trái",
"moveRight": "Dịch ảnh sang phải",
"moveUp": "Dịch ảnh lên trên",
"moveDown": "Dịch ảnh xuống dưới",
"chooseAnother": "Chọn ảnh khác",
"choose": "Chọn ảnh",
"fileHint": "JPEG, PNG, WebP · tối đa 5 MB"
},
"wallet": {
"header": "Ví của tôi",
"detailHeader": "Chi tiết ví",
"summary": "Tổng số dư theo tiền tệ",
"noBalance": "Chưa có số dư",
"activeCount": "{{count}} ví đang hoạt động",
"list": "Danh sách ví",
"syncing": "Đang đồng bộ...",
"create": "Tạo ví",
"createNew": "Tạo ví mới",
"searchLabel": "Tìm kiếm ví",
"searchPlaceholder": "Tìm theo tên, mô tả, tiền tệ...",
"sortLabel": "Sắp xếp ví",
"includeArchived": "Đã lưu trữ",
"loadingList": "Đang tải danh sách ví",
"loadFailed": "Không thể tải danh sách ví",
"connectionFailed": "Vui lòng kiểm tra kết nối và thử lại.",
"notFound": "Không tìm thấy ví phù hợp",
"empty": "Chưa có ví nào",
"first": "Bắt đầu với ví đầu tiên",
"searchHint": "Thử một từ khóa khác hoặc bật danh sách đã lưu trữ.",
"emptyHint": "Tạo ví để theo dõi số dư và quản lý tài chính của bạn.",
"paginationLabel": "Phân trang ví",
"createSuccess": "Đã tạo ví mới thành công.",
"createFailed": "Không thể tạo ví. Vui lòng thử lại.",
"defaultUpdated": "Đã cập nhật ví mặc định.",
"defaultSet": "Đã đặt làm ví mặc định.",
"defaultFailed": "Không thể đặt ví mặc định.",
"setDefault": "Đặt làm ví mặc định",
"detailLoadFailed": "Không thể tải thông tin ví",
"missing": "Ví không tồn tại hoặc kết nối bị gián đoạn.",
"listButton": "Danh sách ví",
"defaultWallet": "Ví mặc định",
"info": "Thông tin ví",
"status": "Trạng thái",
"createdAt": "Ngày tạo",
"updatedAt": "Cập nhật gần nhất",
"management": "Quản lý ví",
"edit": "Chỉnh sửa thông tin",
"archive": "Lưu trữ ví",
"archiveDefaultHint": "Hãy đặt một ví khác làm mặc định trước khi lưu trữ ví này.",
"restore": "Khôi phục ví",
"restoring": "Đang khôi phục...",
"restoreHint": "Nếu chưa có ví hoạt động mặc định, ví này sẽ tự động được chọn sau khi khôi phục.",
"updateSuccess": "Đã lưu thay đổi của ví.",
"updateFailed": "Không thể cập nhật ví.",
"archiveSuccess": "Ví đã được lưu trữ; lịch sử giao dịch vẫn được giữ nguyên.",
"archiveFailed": "Không thể lưu trữ ví.",
"restoreSuccess": "Đã khôi phục ví thành công.",
"restoreFailed": "Không thể khôi phục ví.",
"archiveTitle": "Lưu trữ ví?",
"archiving": "Đang lưu trữ...",
"archiveConfirm": "Xác nhận lưu trữ",
"archiveDescription": "Ví sẽ không còn dùng được cho giao dịch mới, nhưng toàn bộ lịch sử giao dịch và số dư vẫn được bảo toàn. Bạn có thể khôi phục ví bất cứ lúc nào.",
"form": {
"editTitle": "Chỉnh sửa ví",
"createTitle": "Tạo ví mới",
"saveChanges": "Lưu thay đổi",
"preview": "Xem trước ví",
"previewHint": "Chọn biểu tượng và màu nhận diện",
"name": "Tên ví *",
"namePlaceholder": "Ví dụ: Tiền mặt",
"balance": "Số dư *",
"currency": "Tiền tệ *",
"icon": "Biểu tượng",
"color": "Màu ví",
"chooseColor": "Chọn màu {{color}}",
"description": "Mô tả",
"descriptionPlaceholder": "Mục đích sử dụng ví...",
"defaultTitle": "Đặt làm ví mặc định",
"defaultHint": "Ví đầu tiên luôn tự động trở thành ví mặc định."
},
"icons": { "wallet": "Ví", "cash": "Tiền mặt", "bank": "Ngân hàng", "card": "Thẻ", "savings": "Tiết kiệm" },
"sort": { "createdDesc": "Mới tạo gần đây", "updatedDesc": "Mới cập nhật", "nameAsc": "Tên A → Z", "nameDesc": "Tên Z → A", "balanceDesc": "Số dư cao nhất", "balanceAsc": "Số dư thấp nhất" }
},
"styleGuide": {
"header": "Style Guide & Design System",
"title": "Hệ thống giao diện Claymorphism",
"subtitle": "Design token thích ứng sáng/tối cho giao diện đất sét mềm mại của FinWise",
"tabs": { "overview": "Tổng Quan", "income": "Thu Nhập", "expense": "Chi Tiêu" },
"currencies": { "vnd": "Việt Nam Đồng (VND)", "usd": "Đô la Mỹ (USD)", "eur": "Đồng Euro (EUR)" },
"typographyColors": "1. Kiểu chữ & Màu sắc",
"typographyScale": "Thang kiểu chữ",
"typeH1": "clay-title-h1 (32px / Baloo 2):",
"typeH2": "clay-title-h2 (24px / Baloo 2):",
"typeH3": "clay-title-h3 (18px / Baloo 2):",
"typeBody": "clay-body (16px / Nunito):",
"typeCaption": "clay-caption (13px / Nunito):",
"sampleWelcome": "Chào mừng bạn! 5.000.000đ",
"sampleWallet": "Ví của tôi",
"sampleCategory": "Danh mục ăn uống",
"sampleBody": "Đây là nội dung hiển thị bình thường, dễ đọc và tròn trịa.",
"sampleCaption": "Giao dịch thực hiện lúc 12:30 hôm nay",
"semanticColors": "Màu sắc ngữ nghĩa",
"semanticHint": "Dùng nút chuyển ở góc trên bên phải để kiểm tra cùng một semantic token trong cả hai giao diện.",
"appBackground": "Nền ứng dụng",
"cardInput": "Thẻ / Ô nhập",
"primary": "Chính",
"primaryStrong": "Chính đậm",
"incomeSuccess": "Thu nhập / Thành công",
"expenseError": "Chi tiêu / Lỗi",
"warning": "Cảnh báo",
"info": "Thông tin",
"primaryVariant": "Chính:",
"secondaryVariant": "Phụ:",
"ghostVariant": "Trong suốt:",
"buttons": "2. Nút bấm",
"buttonHint": "Nhấn thử nút để trải nghiệm hiệu ứng lún và chuyển động Claymorphism.",
"primaryButton": "Nút Chính",
"pillButton": "Nút Pill",
"disabled": "Vô hiệu hóa",
"secondaryButton": "Nút Phụ",
"pillShape": "Dạng Pill",
"skip": "Bỏ qua",
"fullWidth": "Nút Rộng Toàn Bộ Màn Hình",
"cards": "3. Thẻ",
"raisedCard": "Thẻ nổi (Mặc định)",
"raisedCardHint": "Thẻ nổi trên bề mặt nền mịn màng.",
"hoverableCard": "Thẻ có thể chạm",
"hoverableCardHint": "Khi rê chuột hoặc chạm, thẻ sẽ nổi cao hơn nữa.",
"inputs": "4. Ô nhập & Danh sách chọn",
"inputHint": "Các ô nhập liệu dạng hốc lõm vào trong đất sét.",
"amount": "Số tiền giao dịch (VND)",
"amountPlaceholder": "Nhập số tiền...",
"note": "Ghi chú chi tiêu",
"notePlaceholder": "Ví dụ: Ăn trưa văn phòng...",
"noteError": "Nội dung ghi chú không được để trống",
"currency": "Chọn đơn vị tiền tệ",
"linkedAccount": "Tài khoản liên kết (Khóa)",
"badges": "5. Huy hiệu & Khung biểu tượng",
"badgeTypes": "Huy hiệu trạng thái pastel",
"system": "Hệ thống",
"income": "Thu nhập (+)",
"expense": "Chi tiêu (-)",
"overLimit": "Vượt hạn mức",
"news": "Tin tức",
"iconWrappers": "Khung biểu tượng danh mục",
"smallIncome": "Nhỏ (40px) - Thu nhập",
"mediumExpense": "Vừa (48px) - Chi tiêu",
"largePrimary": "Lớn (64px) - Chính",
"avatar": "6. Ảnh đại diện",
"roundedAvatar": "Ảnh đại diện bo tròn",
"avatarHint": "Viền trắng dày 3px & bóng nhẹ",
"tabsSection": "7. Tab chuyển đổi",
"tabsHint": "Các tab nằm trong rãnh lõm; tab được chọn sẽ nổi lên.",
"showing": "Đang hiển thị nội dung của:",
"progress": "8. Thanh tiến độ",
"progressHint": "Dùng cho quản lý ngân sách & mục tiêu tiết kiệm. Điều chỉnh thanh trượt để xem cập nhật trực quan.",
"adjustProgress": "Chỉnh tiến độ:",
"defaultBudget": "Chính (Ngân sách mặc định):",
"saving": "Thu nhập (Tiết kiệm):",
"spent": "Chi tiêu (Đã chi):",
"nearLimit": "Cảnh báo (Cận hạn mức):",
"modal": "9. Hộp thoại & Sheet",
"openModal": "Mở Hộp Thoại Thử Nghiệm",
"addTransaction": "Thêm Mới Giao Dịch",
"saveTransaction": "Lưu Giao Dịch",
"modalHint": "Hộp thoại có hậu cảnh mờ, góc bo rộng rãi và bóng đổ nổi bật.",
"transactionName": "Tên giao dịch",
"namePlaceholder": "Nhập tên...",
"icons": "10. Biểu tượng SVG 3D",
"home": "Trang chủ",
"wallet": "Ví",
"transaction": "Giao dịch",
"transfer": "Chuyển ví",
"budget": "Ngân sách",
"savings": "Tiết kiệm",
"report": "Báo cáo",
"assistant": "Trợ lý AI",
"notification": "Thông báo",
"account": "Tài khoản",
"add": "Thêm mới",
"food": "Ăn uống"
},
"accessibility": {
"closeModal": "Đóng hộp thoại",
"userAvatar": "Ảnh đại diện người dùng"
}
}
import axios from "axios";
interface ErrorResponseBody {
message?: string;
}
export class LocalizedError extends Error {}
export function getErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ErrorResponseBody>(error)) {
return error.response?.data?.message || fallback;
if (axios.isAxiosError(error)) {
// Backend messages are not guaranteed to match the selected UI locale.
return fallback;
}
if (error instanceof Error && error.message) {
if (error instanceof LocalizedError && error.message) {
return error.message;
}
......
......@@ -3,27 +3,27 @@ import { Wallet } from "@/types/wallet";
export const WALLET_COLORS = ["#8B7CF6", "#60A5FA", "#34D399", "#FBBF24", "#FB7185"] as const;
export const WALLET_ICONS = [
{ value: "wallet", label: "Ví" },
{ value: "cash", label: "Tiền mặt" },
{ value: "bank", label: "Ngân hàng" },
{ value: "card", label: "Thẻ" },
{ value: "savings", label: "Tiết kiệm" },
{ value: "wallet", labelKey: "wallet.icons.wallet" },
{ value: "cash", labelKey: "wallet.icons.cash" },
{ value: "bank", labelKey: "wallet.icons.bank" },
{ value: "card", labelKey: "wallet.icons.card" },
{ value: "savings", labelKey: "wallet.icons.savings" },
] as const;
export function formatWalletBalance(balance: string, currency: string): string {
export function formatWalletBalance(balance: string, currency: string, locale: string): string {
const numericBalance = Number(balance);
if (!Number.isFinite(numericBalance)) {
return `${balance} ${currency}`;
}
try {
return new Intl.NumberFormat("vi-VN", {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(numericBalance);
} catch {
return `${new Intl.NumberFormat("vi-VN", { maximumFractionDigits: 2 }).format(numericBalance)} ${currency}`;
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(numericBalance)} ${currency}`;
}
}
......
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
......@@ -8,18 +8,22 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
const forgotPasswordSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
const createForgotPasswordSchema = (t: TranslationFunction) => z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
});
type ForgotPasswordFormValues = z.infer<typeof forgotPasswordSchema>;
type ForgotPasswordFormValues = z.infer<ReturnType<typeof createForgotPasswordSchema>>;
const ForgotPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isSent, setIsSent] = useState(false);
const { t } = useI18n();
const forgotPasswordSchema = useMemo(() => createForgotPasswordSchema(t), [t]);
const {
register,
......@@ -36,19 +40,19 @@ const ForgotPasswordPage: React.FC = () => {
if (response.success) {
openSnackbar({
type: "success",
text: "Liên kết khôi phục mật khẩu đã được gửi đến email của bạn! ✉️",
text: t("auth.forgot.successToast"),
});
setIsSent(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Gửi yêu cầu thất bại",
text: t("auth.forgot.failed"),
});
}
} catch (error: any) {
} catch (error: unknown) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Gửi yêu cầu thất bại, vui lòng thử lại.",
text: getErrorMessage(error, t("auth.forgot.failedDetail")),
});
} finally {
setIsLoading(false);
......@@ -57,13 +61,13 @@ const ForgotPasswordPage: React.FC = () => {
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Quên Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<Header title={t("auth.forgot.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Quên Mật Khẩu?</h1>
<p className="clay-caption">Đừng lo lắng, hãy nhập email của bạn để bắt đầu khôi phục</p>
<h1 className="clay-title-h1 text-clay-primary">{t("auth.forgot.title")}</h1>
<p className="clay-caption">{t("auth.forgot.subtitle")}</p>
</div>
{isSent ? (
......@@ -73,12 +77,12 @@ const ForgotPasswordPage: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Đã gửi Email thành công</h3>
<h3 className="clay-title-h3 text-clay-primary">{t("auth.forgot.sentTitle")}</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi hướng dẫn đặt lại mật khẩu đến email của bạn. Vui lòng kiểm tra hộp thư đến (và cả hộp thư rác).
{t("auth.forgot.sentDescription")}
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
Nhập mã đặt lại mật khẩu
{t("auth.forgot.enterResetCode")}
</Button>
</Card>
) : (
......@@ -86,7 +90,7 @@ const ForgotPasswordPage: React.FC = () => {
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email liên kết"
label={t("auth.forgot.linkedEmail")}
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
......@@ -98,22 +102,22 @@ const ForgotPasswordPage: React.FC = () => {
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang gửi...</span>
<span>{t("auth.forgot.submitting")}</span>
</div>
) : (
"Gửi Yêu Cầu"
t("auth.forgot.submit")
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Nhớ mật khẩu?{" "}
{t("auth.forgot.rememberPassword")}{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
{t("auth.forgot.login")}
</span>
</div>
</>
......
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
......@@ -10,6 +10,7 @@ import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message";
import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
......@@ -33,13 +34,13 @@ const updateRememberedEmail = (email: string, shouldRemember: boolean): void =>
}
};
const loginSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
password: z.string().min(8, "Mật khẩu phải từ 8 ký tự trở lên"),
const createLoginSchema = (t: TranslationFunction) => z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
password: z.string().min(8, t("validation.passwordMin")),
rememberMe: z.boolean(),
});
type LoginFormValues = z.infer<typeof loginSchema>;
type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
const LoginPage: React.FC = () => {
const navigate = useNavigate();
......@@ -48,6 +49,8 @@ const LoginPage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
const { t } = useI18n();
const loginSchema = useMemo(() => createLoginSchema(t), [t]);
const {
register,
......@@ -74,19 +77,19 @@ const LoginPage: React.FC = () => {
setAuth(response.data.user, "", "");
openSnackbar({
type: "success",
text: "Đăng nhập thành công! 🎉",
text: t("auth.login.success"),
});
navigate("/", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng nhập thất bại",
text: t("auth.login.failed"),
});
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."),
text: getErrorMessage(error, t("auth.login.failedDetail")),
});
} finally {
setIsLoading(false);
......@@ -95,19 +98,19 @@ const LoginPage: React.FC = () => {
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Nhập" showBackIcon={false} />
<Header title={t("auth.login.header")} showBackIcon={false} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào Mừng!</h1>
<p className="clay-caption">Đăng nhập tài khoản FinWise của bạn để bắt đầu</p>
<h1 className="clay-title-h1 text-clay-primary">{t("auth.login.title")}</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email"
label={t("auth.email")}
placeholder="user@gmail.com"
type="email"
autoComplete="email"
......@@ -117,7 +120,7 @@ const LoginPage: React.FC = () => {
/>
<Input
label="Mật khẩu"
label={t("auth.password")}
placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
......@@ -127,7 +130,7 @@ const LoginPage: React.FC = () => {
endAdornment={
<button
type="button"
aria-label={isPasswordVisible ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
aria-label={t(isPasswordVisible ? "auth.login.hidePassword" : "auth.login.showPassword")}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isLoading}
......@@ -146,13 +149,13 @@ const LoginPage: React.FC = () => {
disabled={isLoading}
className="h-4 w-4 cursor-pointer rounded border-clay-text-muted accent-clay-primary transition-all duration-200 ease-in-out focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>Ghi nhớ email</span>
<span>{t("auth.login.rememberEmail")}</span>
</label>
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
>
Quên mật khẩu?
{t("auth.login.forgotPassword")}
</span>
</div>
......@@ -160,22 +163,22 @@ const LoginPage: React.FC = () => {
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng nhập...</span>
<span>{t("auth.login.submitting")}</span>
</div>
) : (
"Đăng Nhập"
t("auth.login.submit")
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Chưa có tài khoản?{" "}
{t("auth.login.noAccount")}{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng ký ngay
{t("auth.login.registerNow")}
</span>
</div>
</div>
......
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
......@@ -8,38 +8,42 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
const registerSchema = z
const createRegisterSchema = (t: TranslationFunction) => z
.object({
fullName: z.string().min(2, "Họ và tên phải từ 2 ký tự trở lên"),
fullName: z.string().min(2, t("validation.fullNameMin")),
email: z
.string()
.min(1, "Email không được để trống")
.email("Email không hợp lệ")
.min(1, t("validation.emailRequired"))
.email(t("validation.emailInvalid"))
.refine((val) => val.endsWith("@gmail.com"), {
message: "Chỉ chấp nhận địa chỉ email @gmail.com",
message: t("validation.gmailOnly"),
}),
password: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.min(8, t("validation.passwordMin"))
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
t("validation.passwordStrength")
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
confirmPassword: z.string().min(1, t("validation.confirmPassword")),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
message: t("validation.passwordMismatch"),
path: ["confirmPassword"],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const { t } = useI18n();
const registerSchema = useMemo(() => createRegisterSchema(t), [t]);
const {
register,
......@@ -61,19 +65,19 @@ const RegisterPage: React.FC = () => {
if (response.success) {
openSnackbar({
type: "success",
text: "Đăng ký thành công! Vui lòng kiểm tra email để kích hoạt.",
text: t("auth.register.success"),
});
setIsRegistered(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng ký thất bại",
text: t("auth.register.failed"),
});
}
} catch (error: any) {
} catch (error: unknown) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Đăng ký thất bại, vui lòng thử lại.",
text: getErrorMessage(error, t("auth.register.failedDetail")),
});
} finally {
setIsLoading(false);
......@@ -82,13 +86,13 @@ const RegisterPage: React.FC = () => {
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Ký" showBackIcon={true} onBackClick={() => navigate("/login")} />
<Header title={t("auth.register.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Tạo Tài Khoản</h1>
<p className="clay-caption">Khởi đầu hành trình quản lý tài chính thông minh</p>
<h1 className="clay-title-h1 text-clay-primary">{t("auth.register.title")}</h1>
<p className="clay-caption">{t("auth.register.subtitle")}</p>
</div>
{isRegistered ? (
......@@ -98,12 +102,12 @@ const RegisterPage: React.FC = () => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 19v-8.93a2 2 0 01.89-1.664l8-4.666a2 2 0 012.22 0l8 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-2.25-1.5a2 2 0 00-2.5 0l-2.25 1.5" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Kiểm tra Email của bạn</h3>
<h3 className="clay-title-h3 text-clay-primary">{t("auth.register.checkEmail")}</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi một liên kết kích hoạt đến email của bạn. Vui lòng nhấp vào liên kết để kích hoạt tài khoản trước khi đăng nhập.
{t("auth.register.checkEmailDescription")}
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/login")}>
Quay lại Đăng Nhập
{t("auth.register.backToLogin")}
</Button>
</Card>
) : (
......@@ -111,8 +115,8 @@ const RegisterPage: React.FC = () => {
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Họ và tên"
placeholder="Nguyễn Văn A"
label={t("auth.register.fullName")}
placeholder={t("auth.register.fullNamePlaceholder")}
type="text"
error={errors.fullName?.message}
{...register("fullName")}
......@@ -120,7 +124,7 @@ const RegisterPage: React.FC = () => {
/>
<Input
label="Email (chỉ nhận @gmail.com)"
label={t("auth.register.gmailLabel")}
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
......@@ -129,7 +133,7 @@ const RegisterPage: React.FC = () => {
/>
<Input
label="Mật khẩu"
label={t("auth.password")}
placeholder="••••••••"
type="password"
error={errors.password?.message}
......@@ -138,7 +142,7 @@ const RegisterPage: React.FC = () => {
/>
<Input
label="Xác nhận mật khẩu"
label={t("auth.register.confirmPassword")}
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
......@@ -150,22 +154,22 @@ const RegisterPage: React.FC = () => {
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng ký...</span>
<span>{t("auth.register.submitting")}</span>
</div>
) : (
"Đăng Ký"
t("auth.register.submit")
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Đã có tài khoản?{" "}
{t("auth.register.hasAccount")}{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
{t("auth.register.login")}
</span>
</div>
</>
......
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
......@@ -8,30 +8,34 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
const resetPasswordSchema = z
const createResetPasswordSchema = (t: TranslationFunction) => z
.object({
token: z.string().uuid("Mã khôi phục không hợp lệ (phải ở định dạng UUID)"),
token: z.string().uuid(t("validation.resetTokenInvalid")),
newPassword: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.min(8, t("validation.passwordMin"))
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
t("validation.passwordStrength")
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
confirmPassword: z.string().min(1, t("validation.confirmPassword")),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
message: t("validation.passwordMismatch"),
path: ["confirmPassword"],
});
type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>;
type ResetPasswordFormValues = z.infer<ReturnType<typeof createResetPasswordSchema>>;
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const { t } = useI18n();
const resetPasswordSchema = useMemo(() => createResetPasswordSchema(t), [t]);
const {
register,
......@@ -52,19 +56,19 @@ const ResetPasswordPage: React.FC = () => {
if (response.success) {
openSnackbar({
type: "success",
text: "Đặt lại mật khẩu thành công! Bạn có thể đăng nhập ngay.",
text: t("auth.reset.success"),
});
navigate("/login", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đặt lại mật khẩu thất bại",
text: t("auth.reset.failed"),
});
}
} catch (error: any) {
} catch (error: unknown) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Mã token không hợp lệ hoặc đã hết hạn.",
text: getErrorMessage(error, t("auth.reset.expired")),
});
} finally {
setIsLoading(false);
......@@ -73,19 +77,19 @@ const ResetPasswordPage: React.FC = () => {
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đặt Lại Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<Header title={t("auth.reset.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Đặt Lại Mật Khẩu</h1>
<p className="clay-caption">Nhập mã đặt lại nhận được trong email và mật khẩu mới của bạn</p>
<h1 className="clay-title-h1 text-clay-primary">{t("auth.reset.title")}</h1>
<p className="clay-caption">{t("auth.reset.subtitle")}</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Mã khôi phục (Token UUID)"
label={t("auth.reset.token")}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
type="text"
error={errors.token?.message}
......@@ -94,7 +98,7 @@ const ResetPasswordPage: React.FC = () => {
/>
<Input
label="Mật khẩu mới"
label={t("auth.reset.newPassword")}
placeholder="••••••••"
type="password"
error={errors.newPassword?.message}
......@@ -103,7 +107,7 @@ const ResetPasswordPage: React.FC = () => {
/>
<Input
label="Xác nhận mật khẩu mới"
label={t("auth.reset.confirmNewPassword")}
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
......@@ -115,22 +119,22 @@ const ResetPasswordPage: React.FC = () => {
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang xử lý...</span>
<span>{t("common.processing")}</span>
</div>
) : (
"Xác Nhận Đổi Mật Khẩu"
t("auth.reset.submit")
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Quay lại{" "}
{t("auth.reset.back")}{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
{t("auth.reset.login")}
</span>
</div>
</div>
......
......@@ -5,14 +5,16 @@ import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients, WalletIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
function HomePage() {
const navigate = useNavigate();
const { user } = useAuthStore();
const { t } = useI18n();
return (
<Page className="page flex flex-col justify-between py-8">
<Header title="FinWise Mini App" showBackIcon={false} />
<Header title={t("home.header")} showBackIcon={false} />
<IconGradients />
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
......@@ -32,18 +34,18 @@ function HomePage() {
{/* Text Area */}
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào, {user?.fullName || "Người dùng"}!</h1>
<h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2>
<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>
<p className="clay-caption max-w-xs mx-auto">
Tài khoản: <span className="font-semibold text-clay-primary">{user?.email}</span>
{t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span>
</p>
</div>
{/* Info Card */}
<Card className="w-full max-w-sm">
<h3 className="font-baloo font-bold text-clay-text text-base mb-1">Xác thực hệ thống</h3>
<h3 className="font-baloo font-bold text-clay-text text-base mb-1">{t("home.authTitle")}</h3>
<p className="text-xs text-clay-text-muted">
Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.
{t("home.authDescription")}
</p>
</Card>
</div>
......@@ -56,21 +58,21 @@ function HomePage() {
onClick={() => navigate("/wallets")}
className="gap-2"
>
Quản lý ví
{t("home.wallets")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/profile")}
>
Trang cá nhân của tôi
{t("home.profile")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/style-guide")}
>
Khám phá Style Guide
{t("home.styleGuide")}
</Button>
</div>
</Page>
......
......@@ -2,6 +2,7 @@ import React, { PointerEvent, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useI18n } from "@/i18n";
export interface AvatarPosition {
x: number;
......@@ -62,7 +63,10 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
decreaseLabel,
increaseLabel,
onChange,
}) => (
}) => {
const { t } = useI18n();
return (
<div
className={`grid w-full grid-cols-[4.5rem_1fr_2.5rem] items-center gap-2 transition-all duration-200 ease-in-out ${locked ? "opacity-55" : ""}`}
title={locked ? lockedMessage : undefined}
......@@ -71,7 +75,7 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
{label}
{locked && (
<svg
aria-label="Đang khóa"
aria-label={t("avatarEditor.locked")}
viewBox="0 0 24 24"
className="h-3.5 w-3.5 text-clay-text-muted"
fill="none"
......@@ -121,7 +125,8 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
{displayValue}
</span>
</div>
);
);
};
export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
isOpen,
......@@ -135,6 +140,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onSave,
onDelete,
}) => {
const { t } = useI18n();
const dragStart = useRef<DragStart | null>(null);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const canAdjustHorizontal = imageSize !== null
......@@ -185,7 +191,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
<Modal
isOpen={isOpen}
onClose={isSaving ? () => undefined : onClose}
title="Chỉnh ảnh đại diện"
title={t("avatarEditor.title")}
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
{hasStoredAvatar && (
......@@ -196,7 +202,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onDelete}
className="mr-auto px-3 text-sm text-clay-expense"
>
Xóa ảnh
{t("avatarEditor.delete")}
</Button>
)}
<Button
......@@ -206,7 +212,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onClose}
className="px-3 text-sm"
>
Hủy
{t("common.cancel")}
</Button>
<Button
type="button"
......@@ -215,7 +221,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onSave}
className="px-4 text-sm"
>
{isSaving ? "Đang lưu..." : "Lưu ảnh"}
{isSaving ? t("common.saving") : t("avatarEditor.save")}
</Button>
</div>
}
......@@ -223,7 +229,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
<div className="flex flex-col items-center gap-4">
<div
role="application"
aria-label="Kéo để chọn vùng ảnh đại diện"
aria-label={t("avatarEditor.dragLabel")}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
......@@ -233,7 +239,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
{imageUrl ? (
<img
src={imageUrl}
alt="Xem trước ảnh đại diện"
alt={t("avatarEditor.previewAlt")}
draggable={false}
className="h-full w-full pointer-events-none rounded-full object-cover"
style={{
......@@ -246,19 +252,19 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
/>
) : (
<div className="flex h-full w-full items-center justify-center px-8 text-center font-nunito text-sm font-semibold text-clay-text-muted">
Chọn một ảnh để bắt đầu
{t("avatarEditor.selectToStart")}
</div>
)}
</div>
<p className="clay-caption text-center">
Kéo ảnh trong khung tròn hoặc dùng thanh trượt để chọn vùng hiển thị.
{t("avatarEditor.instruction")}
</p>
<div className="flex w-full flex-col gap-3">
<AvatarAdjustmentControl
id="avatar-position-x"
label="Ngang"
label={t("avatarEditor.horizontal")}
value={position.x}
displayValue={`${position.x}%`}
min={0}
......@@ -267,14 +273,14 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustHorizontal}
locked={Boolean(imageUrl && imageSize && !canAdjustHorizontal)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều ngang."
decreaseLabel="Dịch ảnh sang trái"
increaseLabel="Dịch ảnh sang phải"
lockedMessage={t("avatarEditor.horizontalLocked")}
decreaseLabel={t("avatarEditor.moveLeft")}
increaseLabel={t("avatarEditor.moveRight")}
onChange={(x) => onPositionChange({ ...position, x })}
/>
<AvatarAdjustmentControl
id="avatar-position-y"
label="Dọc"
label={t("avatarEditor.vertical")}
value={position.y}
displayValue={`${position.y}%`}
min={0}
......@@ -283,15 +289,15 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustVertical}
locked={Boolean(imageUrl && imageSize && !canAdjustVertical)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều dọc."
decreaseLabel="Dịch ảnh lên trên"
increaseLabel="Dịch ảnh xuống dưới"
lockedMessage={t("avatarEditor.verticalLocked")}
decreaseLabel={t("avatarEditor.moveUp")}
increaseLabel={t("avatarEditor.moveDown")}
onChange={(y) => onPositionChange({ ...position, y })}
/>
</div>
<label className="inline-flex cursor-pointer items-center rounded-full bg-clay-surface px-5 py-2.5 font-nunito text-sm font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed">
{imageUrl ? "Chọn ảnh khác" : "Chọn ảnh"}
{imageUrl ? t("avatarEditor.chooseAnother") : t("avatarEditor.choose")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
......@@ -304,7 +310,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
}}
/>
</label>
<span className="clay-caption">JPEG, PNG, WebP · tối đa 5 MB</span>
<span className="clay-caption">{t("avatarEditor.fileHint")}</span>
</div>
</Modal>
);
......
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
......@@ -21,39 +21,41 @@ import {
AvatarEditorModal,
AvatarPosition,
} from "@/pages/profile/avatar-editor-modal";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage, LocalizedError } from "@/lib/error-message";
const AVATAR_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
const AVATAR_ACCEPT = "image/jpeg,image/png,image/webp";
// Zod schemas for forms
const profileSchema = z.object({
fullName: z.string().min(2, "Họ và tên phải từ 2 ký tự trở lên"),
const createProfileSchema = (t: TranslationFunction) => z.object({
fullName: z.string().min(2, t("validation.fullNameMin")),
phoneNumber: z
.string()
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, "Số điện thoại không hợp lệ (ví dụ: 0912345678)")
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, t("validation.phoneInvalid"))
.or(z.literal("")),
avatarUrl: z.string().url("Đường dẫn ảnh đại diện không hợp lệ").or(z.literal("")),
avatarUrl: z.string().url(t("validation.avatarUrlInvalid")).or(z.literal("")),
});
const passwordSchema = z
const createPasswordSchema = (t: TranslationFunction) => z
.object({
oldPassword: z.string().min(1, "Vui lòng nhập mật khẩu hiện tại"),
oldPassword: z.string().min(1, t("validation.currentPasswordRequired")),
newPassword: z
.string()
.min(8, "Mật khẩu mới phải từ 8 ký tự trở lên")
.min(8, t("validation.newPasswordMin"))
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
t("validation.passwordStrength")
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu mới"),
confirmPassword: z.string().min(1, t("validation.confirmNewPassword")),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
message: t("validation.passwordMismatch"),
path: ["confirmPassword"],
});
type ProfileFormValues = z.infer<typeof profileSchema>;
type PasswordFormValues = z.infer<typeof passwordSchema>;
type ProfileFormValues = z.infer<ReturnType<typeof createProfileSchema>>;
type PasswordFormValues = z.infer<ReturnType<typeof createPasswordSchema>>;
const ProfilePage: React.FC = () => {
const navigate = useNavigate();
......@@ -61,6 +63,9 @@ const ProfilePage: React.FC = () => {
const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const { formatDate, t } = useI18n();
const profileSchema = useMemo(() => createProfileSchema(t), [t]);
const passwordSchema = useMemo(() => createPasswordSchema(t), [t]);
const [activeTab, setActiveTab] = useState("profile");
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
......@@ -71,9 +76,9 @@ const ProfilePage: React.FC = () => {
});
const tabs = [
{ key: "profile", label: "Thông Tin" },
{ key: "password", label: "Mật Khẩu" },
{ key: "sessions", label: "Thiết Bị" },
{ key: "profile", label: t("profile.tabs.info") },
{ key: "password", label: t("profile.tabs.password") },
{ key: "sessions", label: t("profile.tabs.devices") },
];
// Forms setup
......@@ -117,7 +122,7 @@ const ProfilePage: React.FC = () => {
onSuccess: (response) => {
openSnackbar({
type: "success",
text: "Cập nhật thông tin cá nhân thành công! 🎉",
text: t("profile.updateSuccess"),
});
if (response.success && response.data) {
setUser(response.data);
......@@ -129,13 +134,9 @@ const ProfilePage: React.FC = () => {
}
},
onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({
type: "error",
text:
apiMessage ||
(error instanceof Error ? error.message : "Cập nhật thất bại, vui lòng thử lại."),
text: getErrorMessage(error, t("profile.updateFailed")),
});
},
});
......@@ -148,7 +149,7 @@ const ProfilePage: React.FC = () => {
avatarUrl = upload.publicUrl;
}
if (!avatarUrl) {
throw new Error("Vui lòng chọn ảnh đại diện trước khi lưu.");
throw new LocalizedError(t("profile.avatarSelectRequired"));
}
const response = await authService.updateProfile({
......@@ -157,7 +158,7 @@ const ProfilePage: React.FC = () => {
avatarPositionY: avatarPosition.y,
});
if (!response.success || !response.data) {
throw new Error("Không thể lưu ảnh đại diện vào hồ sơ.");
throw new LocalizedError(t("profile.avatarSaveFailed"));
}
return response.data;
},
......@@ -169,17 +170,13 @@ const ProfilePage: React.FC = () => {
setIsAvatarEditorOpen(false);
openSnackbar({
type: "success",
text: "Đã lưu ảnh và vùng hiển thị!",
text: t("profile.avatarSaveSuccess"),
});
},
onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({
type: "error",
text:
apiMessage ||
(error instanceof Error ? error.message : "Tải ảnh đại diện thất bại."),
text: getErrorMessage(error, t("profile.avatarUploadFailed")),
});
},
});
......@@ -188,7 +185,7 @@ const ProfilePage: React.FC = () => {
mutationFn: async () => {
const response = await authService.updateProfile({ avatarUrl: null });
if (!response.success || !response.data) {
throw new Error("Không thể xóa ảnh đại diện.");
throw new LocalizedError(t("profile.avatarDeleteFailed"));
}
return response.data;
},
......@@ -201,17 +198,13 @@ const ProfilePage: React.FC = () => {
setIsAvatarEditorOpen(false);
openSnackbar({
type: "success",
text: "Đã xóa ảnh đại diện.",
text: t("profile.avatarDeleteSuccess"),
});
},
onError: (error: unknown) => {
const apiMessage = (error as { response?: { data?: { message?: string } } })
.response?.data?.message;
openSnackbar({
type: "error",
text:
apiMessage ||
(error instanceof Error ? error.message : "Không thể xóa ảnh đại diện."),
text: getErrorMessage(error, t("profile.avatarDeleteFailed")),
});
},
});
......@@ -221,14 +214,14 @@ const ProfilePage: React.FC = () => {
onSuccess: () => {
openSnackbar({
type: "success",
text: "Thay đổi mật khẩu thành công! 🔑",
text: t("profile.passwordSuccess"),
});
resetPasswordForm();
},
onError: (error: any) => {
onError: (error: unknown) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Thay đổi mật khẩu thất bại.",
text: getErrorMessage(error, t("profile.passwordFailed")),
});
},
});
......@@ -238,14 +231,14 @@ const ProfilePage: React.FC = () => {
onSuccess: () => {
openSnackbar({
type: "success",
text: "Đã đóng phiên đăng nhập thành công.",
text: t("profile.revokeSuccess"),
});
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
onError: (error: any) => {
onError: (error: unknown) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Không thể đóng phiên đăng nhập.",
text: getErrorMessage(error, t("profile.revokeFailed")),
});
},
});
......@@ -255,14 +248,14 @@ const ProfilePage: React.FC = () => {
onSuccess: () => {
openSnackbar({
type: "success",
text: "Đã đăng xuất khỏi tất cả các thiết bị khác thành công! 📱",
text: t("profile.logoutOthersSuccess"),
});
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
onError: (error: any) => {
onError: (error: unknown) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Không thể đăng xuất các thiết bị khác.",
text: getErrorMessage(error, t("profile.logoutOthersFailed")),
});
},
});
......@@ -272,7 +265,7 @@ const ProfilePage: React.FC = () => {
await authService.logout();
openSnackbar({
type: "success",
text: "Đăng xuất thành công. Hẹn gặp lại bạn! 👋",
text: t("profile.logoutSuccess"),
});
} catch (err) {
// Still clear local auth state if logout call fails (e.g. server down)
......@@ -290,14 +283,14 @@ const ProfilePage: React.FC = () => {
if (!AVATAR_ACCEPT.split(",").includes(file.type)) {
openSnackbar({
type: "error",
text: "Ảnh đại diện phải là JPEG, PNG hoặc WebP.",
text: t("profile.avatarTypeInvalid"),
});
return;
}
if (file.size > AVATAR_MAX_FILE_SIZE_BYTES) {
openSnackbar({
type: "error",
text: "Ảnh đại diện không được vượt quá 5 MB.",
text: t("profile.avatarSizeInvalid"),
});
return;
}
......@@ -313,7 +306,7 @@ const ProfilePage: React.FC = () => {
reader.onerror = () => {
openSnackbar({
type: "error",
text: "Không thể đọc ảnh đã chọn. Vui lòng thử ảnh khác.",
text: t("profile.avatarReadFailed"),
});
};
reader.readAsDataURL(file);
......@@ -345,7 +338,7 @@ const ProfilePage: React.FC = () => {
return (
<Page className="page">
<Header title="Tài Khoản" showBackIcon={true} onBackClick={() => navigate("/")} />
<Header title={t("profile.header")} showBackIcon={true} onBackClick={() => navigate("/")} />
<IconGradients />
<div className="flex flex-col gap-6 mt-4 pb-20">
......@@ -353,7 +346,7 @@ const ProfilePage: React.FC = () => {
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/50">
<button
type="button"
aria-label="Chỉnh ảnh đại diện"
aria-label={t("profile.editAvatar")}
onClick={openAvatarEditor}
className="group relative rounded-full transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
>
......@@ -372,19 +365,19 @@ const ProfilePage: React.FC = () => {
</span>
</button>
<div className="flex-1 min-w-0">
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || "Chưa thiết lập"}</h2>
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || t("profile.notSet")}</h2>
<p className="clay-caption truncate">{user?.email}</p>
<div className="mt-1 flex gap-2">
<Badge type="primary">{user?.role?.name || "USER"}</Badge>
{user?.isActive && <Badge type="income">Đang hoạt động</Badge>}
{user?.isActive && <Badge type="income">{t("common.active")}</Badge>}
</div>
</div>
</Card>
<Card className="flex items-center justify-between gap-4 p-4">
<div className="min-w-0">
<h3 className="clay-title-h3">Giao diện</h3>
<p className="clay-caption">{theme === "dark" ? "Chế độ tối" : "Chế độ sáng"} · lựa chọn được lưu trên thiết bị</p>
<h3 className="clay-title-h3">{t("theme.section")}</h3>
<p className="clay-caption">{t(theme === "dark" ? "theme.dark" : "theme.light")} · {t("theme.saved")}</p>
</div>
<ThemeToggle theme={theme} onChange={setTheme} />
</Card>
......@@ -395,19 +388,19 @@ const ProfilePage: React.FC = () => {
{/* Tab contents */}
{activeTab === "profile" && (
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Thông tin cá nhân</h3>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">{t("profile.personalInfo")}</h3>
<form onSubmit={handleProfileSubmit(onProfileSubmit)} className="flex flex-col gap-4">
<Input
label="Họ và tên"
placeholder="Nhập họ và tên..."
label={t("profile.fullName")}
placeholder={t("profile.fullNamePlaceholder")}
error={profileErrors.fullName?.message}
{...registerProfile("fullName")}
disabled={updateProfileMutation.isPending}
/>
<Input
label="Số điện thoại"
placeholder="Nhập số điện thoại..."
label={t("profile.phone")}
placeholder={t("profile.phonePlaceholder")}
error={profileErrors.phoneNumber?.message}
{...registerProfile("phoneNumber")}
disabled={updateProfileMutation.isPending}
......@@ -422,7 +415,7 @@ const ProfilePage: React.FC = () => {
disabled={updateProfileMutation.isPending}
className="mt-2"
>
{updateProfileMutation.isPending ? "Đang lưu..." : "Cập Nhật Thông Tin"}
{updateProfileMutation.isPending ? t("common.saving") : t("profile.update")}
</Button>
</form>
</Card>
......@@ -430,11 +423,11 @@ const ProfilePage: React.FC = () => {
{activeTab === "password" && (
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Đổi mật khẩu</h3>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">{t("profile.changePassword")}</h3>
<form onSubmit={handlePasswordSubmit(onPasswordSubmit)} className="flex flex-col gap-4">
<Input
label="Mật khẩu hiện tại"
placeholder="Nhập mật khẩu hiện tại..."
label={t("profile.currentPassword")}
placeholder={t("profile.currentPasswordPlaceholder")}
type="password"
error={passwordErrors.oldPassword?.message}
{...registerPassword("oldPassword")}
......@@ -442,8 +435,8 @@ const ProfilePage: React.FC = () => {
/>
<Input
label="Mật khẩu mới"
placeholder="Mật khẩu mới ít nhất 8 ký tự..."
label={t("profile.newPassword")}
placeholder={t("profile.newPasswordPlaceholder")}
type="password"
error={passwordErrors.newPassword?.message}
{...registerPassword("newPassword")}
......@@ -451,8 +444,8 @@ const ProfilePage: React.FC = () => {
/>
<Input
label="Xác nhận mật khẩu mới"
placeholder="Nhập lại mật khẩu mới..."
label={t("profile.confirmNewPassword")}
placeholder={t("profile.confirmNewPasswordPlaceholder")}
type="password"
error={passwordErrors.confirmPassword?.message}
{...registerPassword("confirmPassword")}
......@@ -466,7 +459,7 @@ const ProfilePage: React.FC = () => {
disabled={updatePasswordMutation.isPending}
className="mt-2"
>
{updatePasswordMutation.isPending ? "Đang cập nhật..." : "Đổi Mật Khẩu"}
{updatePasswordMutation.isPending ? t("common.updating") : t("profile.passwordSubmit")}
</Button>
</form>
</Card>
......@@ -476,9 +469,9 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-4">
{/* Revoke All Other Devices Option */}
<Card className="flex flex-col gap-3">
<h3 className="clay-title-h3 text-clay-primary-dark">Quản lý phiên đăng nhập</h3>
<h3 className="clay-title-h3 text-clay-primary-dark">{t("profile.sessionManagement")}</h3>
<p className="clay-caption">
Bảo vệ tài khoản bằng cách đăng xuất khỏi tất cả các thiết bị và trình duyệt khác mà bạn đã đăng nhập trước đó.
{t("profile.sessionDescription")}
</p>
<Button
variant="secondary"
......@@ -486,16 +479,16 @@ const ProfilePage: React.FC = () => {
disabled={revokeOtherSessionsMutation.isPending}
fullWidth
>
{revokeOtherSessionsMutation.isPending ? "Đang xử lý..." : "Đăng xuất các thiết bị khác"}
{revokeOtherSessionsMutation.isPending ? t("common.processing") : t("profile.logoutOthers")}
</Button>
</Card>
{/* List of active sessions */}
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Thiết bị đang hoạt động</h3>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">{t("profile.activeDevices")}</h3>
{sessionsLoading && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3" aria-label={t("profile.sessionsLoading")}>
{[1, 2].map((i) => (
<div key={i} className="animate-pulse bg-clay-bg h-16 w-full rounded-clay-sm"></div>
))}
......@@ -505,7 +498,7 @@ const ProfilePage: React.FC = () => {
{sessionsError && (
<div className="text-center py-4">
<span className="text-clay-expense text-sm font-semibold">
Không thể tải danh sách phiên. Vui lòng thử lại.
{t("profile.sessionsFailed")}
</span>
</div>
)}
......@@ -520,17 +513,17 @@ const ProfilePage: React.FC = () => {
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-nunito font-bold text-sm text-clay-text truncate">
{session.deviceName || "Thiết bị không tên"}
{session.deviceName || t("profile.unnamedDevice")}
</span>
{session.isCurrent && (
<span className="bg-clay-income/25 text-clay-income text-[10px] font-bold px-2 py-0.5 rounded-full border border-clay-income/20">
Thiết bị này
{t("profile.thisDevice")}
</span>
)}
</div>
<p className="clay-caption text-[11px] mt-0.5">IP: {session.ipAddress}</p>
<p className="clay-caption text-[11px] mt-0.5">{t("profile.ipAddress", { address: session.ipAddress })}</p>
<p className="clay-caption text-[11px]">
Hoạt động từ: {new Date(session.createdAt).toLocaleString()}
{t("profile.activeSince", { date: formatDate(session.createdAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) })}
</p>
</div>
......@@ -540,14 +533,14 @@ const ProfilePage: React.FC = () => {
disabled={revokeSessionMutation.isPending}
className="text-xs font-nunito font-semibold text-clay-expense hover:underline bg-transparent border-none p-1 focus:outline-none disabled:opacity-50"
>
Đăng xuất
{t("profile.logoutDevice")}
</button>
)}
</div>
))}
{sessionsResponse.data.length === 0 && (
<p className="text-center clay-caption py-4">Không có phiên đăng nhập nào khác.</p>
<p className="text-center clay-caption py-4">{t("profile.noSessions")}</p>
)}
</div>
)}
......@@ -558,7 +551,7 @@ const ProfilePage: React.FC = () => {
{/* Global Logout Button */}
<div className="px-4">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
Đăng Xuất
{t("profile.logout")}
</Button>
</div>
</div>
......
......@@ -25,14 +25,16 @@ import {
PlusIcon,
FoodIcon,
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
const StyleGuidePage: React.FC = () => {
const { t } = useI18n();
// State for interactive tab component
const [activeTab, setActiveTab] = useState("tab-1");
const tabItems = [
{ key: "tab-1", label: "Tổng Quan" },
{ key: "tab-2", label: "Thu Nhập" },
{ key: "tab-3", label: "Chi Tiêu" },
{ key: "tab-1", label: t("styleGuide.tabs.overview") },
{ key: "tab-2", label: t("styleGuide.tabs.income") },
{ key: "tab-3", label: t("styleGuide.tabs.expense") },
];
// State for Modal component
......@@ -43,14 +45,14 @@ const StyleGuidePage: React.FC = () => {
// Options for select component
const selectOptions = [
{ value: "vnd", label: "Việt Nam Đồng (VND)" },
{ value: "usd", label: "Đô la Mỹ (USD)" },
{ value: "eur", label: "Đồng Euro (EUR)" },
{ value: "vnd", label: t("styleGuide.currencies.vnd") },
{ value: "usd", label: t("styleGuide.currencies.usd") },
{ value: "eur", label: t("styleGuide.currencies.eur") },
];
return (
<Page className="page">
<Header title="Style Guide & Design System" showBackIcon={false} />
<Header title={t("styleGuide.header")} showBackIcon={false} />
{/* Load SVG Gradients for Claymorphism Icons */}
<IconGradients />
......@@ -59,141 +61,141 @@ const StyleGuidePage: React.FC = () => {
{/* Intro */}
<div className="text-center px-4">
<h1 className="clay-title-h1 text-clay-primary">Claymorphism Theme System</h1>
<h1 className="clay-title-h1 text-clay-primary">{t("styleGuide.title")}</h1>
<p className="clay-caption mt-1">
Design tokens thích ứng Light/Dark cho giao diện đất sét mềm mại của FinWise
{t("styleGuide.subtitle")}
</p>
</div>
{/* Section: Bảng màu & Typography */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">1. Typography & Colors</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.typographyColors")}</h2>
{/* Typography */}
<div className="flex flex-col gap-3 mb-6">
<h3 className="clay-title-h3 text-clay-text/60 mb-1">Typography Scale</h3>
<h3 className="clay-title-h3 text-clay-text/60 mb-1">{t("styleGuide.typographyScale")}</h3>
<div>
<span className="text-xs text-clay-text-muted">clay-title-h1 (32px / Baloo 2):</span>
<h1 className="clay-title-h1">Chào mừng bạn! 5.000.000đ</h1>
<span className="text-xs text-clay-text-muted">{t("styleGuide.typeH1")}</span>
<h1 className="clay-title-h1">{t("styleGuide.sampleWelcome")}</h1>
</div>
<div>
<span className="text-xs text-clay-text-muted">clay-title-h2 (24px / Baloo 2):</span>
<h2 className="clay-title-h2">Ví của tôi</h2>
<span className="text-xs text-clay-text-muted">{t("styleGuide.typeH2")}</span>
<h2 className="clay-title-h2">{t("styleGuide.sampleWallet")}</h2>
</div>
<div>
<span className="text-xs text-clay-text-muted">clay-title-h3 (18px / Baloo 2):</span>
<h3 className="clay-title-h3">Danh mục ăn uống</h3>
<span className="text-xs text-clay-text-muted">{t("styleGuide.typeH3")}</span>
<h3 className="clay-title-h3">{t("styleGuide.sampleCategory")}</h3>
</div>
<div>
<span className="text-xs text-clay-text-muted">clay-body (16px / Nunito):</span>
<p className="clay-body">Đây là nội dung hiển thị bình thường, dễ đọc và tròn trịa.</p>
<span className="text-xs text-clay-text-muted">{t("styleGuide.typeBody")}</span>
<p className="clay-body">{t("styleGuide.sampleBody")}</p>
</div>
<div>
<span className="text-xs text-clay-text-muted">clay-caption (13px / Nunito):</span>
<p className="clay-caption">Giao dịch thực hiện lúc 12:30 hôm nay</p>
<span className="text-xs text-clay-text-muted">{t("styleGuide.typeCaption")}</span>
<p className="clay-caption">{t("styleGuide.sampleCaption")}</p>
</div>
</div>
{/* Color Palette */}
<div>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">Màu sắc Semantic</h3>
<p className="clay-caption mb-3">Dùng nút chuyển ở góc trên bên phải để kiểm tra cùng một semantic token trong cả hai giao diện.</p>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">{t("styleGuide.semanticColors")}</h3>
<p className="clay-caption mb-3">{t("styleGuide.semanticHint")}</p>
<div className="grid grid-cols-2 gap-3 text-xs font-semibold">
<div className="bg-clay-bg text-clay-text p-3 rounded-clay-sm border border-clay-border">Nền ứng dụng</div>
<div className="bg-clay-surface text-clay-text p-3 rounded-clay-sm border border-clay-highlight/60 shadow-clay-pressed">Card / Input</div>
<div className="bg-clay-primary text-clay-on-primary p-3 rounded-clay-sm">Primary</div>
<div className="bg-clay-primary-dark text-clay-on-primary p-3 rounded-clay-sm">Primary strong</div>
<div className="bg-clay-income text-clay-on-status p-3 rounded-clay-sm">Income / Success</div>
<div className="bg-clay-expense text-clay-on-status p-3 rounded-clay-sm">Expense / Error</div>
<div className="bg-clay-warning text-clay-on-status p-3 rounded-clay-sm">Warning</div>
<div className="bg-clay-info text-clay-on-status p-3 rounded-clay-sm">Info</div>
<div className="bg-clay-bg text-clay-text p-3 rounded-clay-sm border border-clay-border">{t("styleGuide.appBackground")}</div>
<div className="bg-clay-surface text-clay-text p-3 rounded-clay-sm border border-clay-highlight/60 shadow-clay-pressed">{t("styleGuide.cardInput")}</div>
<div className="bg-clay-primary text-clay-on-primary p-3 rounded-clay-sm">{t("styleGuide.primary")}</div>
<div className="bg-clay-primary-dark text-clay-on-primary p-3 rounded-clay-sm">{t("styleGuide.primaryStrong")}</div>
<div className="bg-clay-income text-clay-on-status p-3 rounded-clay-sm">{t("styleGuide.incomeSuccess")}</div>
<div className="bg-clay-expense text-clay-on-status p-3 rounded-clay-sm">{t("styleGuide.expenseError")}</div>
<div className="bg-clay-warning text-clay-on-status p-3 rounded-clay-sm">{t("styleGuide.warning")}</div>
<div className="bg-clay-info text-clay-on-status p-3 rounded-clay-sm">{t("styleGuide.info")}</div>
</div>
</div>
</Card>
{/* Section: Buttons */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">2. Buttons (Nút bấm)</h2>
<p className="clay-caption mb-4">Ấn thử nút để trải nghiệm hiệu ứng lún (shadow-clay-pressed) & tịnh tiến tịt xuống chân thực!</p>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.buttons")}</h2>
<p className="clay-caption mb-4">{t("styleGuide.buttonHint")}</p>
<div className="flex flex-col gap-4">
{/* Primary Variant */}
<div className="flex flex-wrap gap-3 items-center">
<span className="w-24 text-xs font-semibold text-clay-text-muted">Primary:</span>
<Button variant="primary">Nút Chính</Button>
<Button variant="primary" shape="pill">Nút Pill</Button>
<Button variant="primary" disabled>Disabled</Button>
<span className="w-24 text-xs font-semibold text-clay-text-muted">{t("styleGuide.primaryVariant")}</span>
<Button variant="primary">{t("styleGuide.primaryButton")}</Button>
<Button variant="primary" shape="pill">{t("styleGuide.pillButton")}</Button>
<Button variant="primary" disabled>{t("styleGuide.disabled")}</Button>
</div>
{/* Secondary Variant */}
<div className="flex flex-wrap gap-3 items-center">
<span className="w-24 text-xs font-semibold text-clay-text-muted">Secondary:</span>
<Button variant="secondary">Nút Phụ</Button>
<Button variant="secondary" shape="pill">Pill Shape</Button>
<Button variant="secondary" disabled>Disabled</Button>
<span className="w-24 text-xs font-semibold text-clay-text-muted">{t("styleGuide.secondaryVariant")}</span>
<Button variant="secondary">{t("styleGuide.secondaryButton")}</Button>
<Button variant="secondary" shape="pill">{t("styleGuide.pillShape")}</Button>
<Button variant="secondary" disabled>{t("styleGuide.disabled")}</Button>
</div>
{/* Ghost Variant */}
<div className="flex flex-wrap gap-3 items-center">
<span className="w-24 text-xs font-semibold text-clay-text-muted">Ghost:</span>
<Button variant="ghost">Bỏ qua</Button>
<Button variant="ghost" disabled>Disabled</Button>
<span className="w-24 text-xs font-semibold text-clay-text-muted">{t("styleGuide.ghostVariant")}</span>
<Button variant="ghost">{t("styleGuide.skip")}</Button>
<Button variant="ghost" disabled>{t("styleGuide.disabled")}</Button>
</div>
{/* Full Width Button */}
<div className="mt-2">
<Button variant="primary" fullWidth>Nút Rộng Toàn Bộ Màn Hình</Button>
<Button variant="primary" fullWidth>{t("styleGuide.fullWidth")}</Button>
</div>
</div>
</Card>
{/* Section: Cards */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">3. Cards (Thẻ)</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.cards")}</h2>
<div className="flex flex-col gap-4">
<Card className="bg-clay-surface p-4 border border-clay-highlight/50">
<h4 className="font-baloo font-bold text-clay-text">Raised Card (Mặc định)</h4>
<p className="text-sm text-clay-text-muted">Thẻ nổi trên bề mặt nền mịn màng.</p>
<h4 className="font-baloo font-bold text-clay-text">{t("styleGuide.raisedCard")}</h4>
<p className="text-sm text-clay-text-muted">{t("styleGuide.raisedCardHint")}</p>
</Card>
<Card hoverable className="p-4">
<h4 className="font-baloo font-bold text-clay-primary">Hoverable / Clickable Card</h4>
<p className="text-sm text-clay-text-muted">Khi hover hoặc chạm, thẻ sẽ nổi cao hơn nữa.</p>
<h4 className="font-baloo font-bold text-clay-primary">{t("styleGuide.hoverableCard")}</h4>
<p className="text-sm text-clay-text-muted">{t("styleGuide.hoverableCardHint")}</p>
</Card>
</div>
</Card>
{/* Section: Inputs & Selects */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">4. Inputs & Selects</h2>
<p className="clay-caption mb-4">Các ô nhập liệu dạng "hốc" lõm vào trong đất sét (shadow-clay-pressed inset).</p>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.inputs")}</h2>
<p className="clay-caption mb-4">{t("styleGuide.inputHint")}</p>
<div className="flex flex-col gap-4">
{/* TextInput */}
<Input
label="Số tiền giao dịch (VND)"
placeholder="Nhập số tiền..."
label={t("styleGuide.amount")}
placeholder={t("styleGuide.amountPlaceholder")}
type="number"
/>
{/* TextInput with error */}
<Input
label="Ghi chú chi tiêu"
placeholder="Ví dụ: Ăn trưa văn phòng..."
error="Nội dung ghi chú không được để trống"
label={t("styleGuide.note")}
placeholder={t("styleGuide.notePlaceholder")}
error={t("styleGuide.noteError")}
defaultValue=""
/>
{/* Select Dropdown */}
<Select
label="Chọn đơn vị tiền tệ"
label={t("styleGuide.currency")}
options={selectOptions}
defaultValue="vnd"
/>
{/* Disabled Input */}
<Input
label="Tài khoản liên kết (Khóa)"
label={t("styleGuide.linkedAccount")}
value="ZaloPay - 0987******"
disabled
/>
......@@ -202,41 +204,41 @@ const StyleGuidePage: React.FC = () => {
{/* Section: Badges & Icon Wrappers */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">5. Badges & Icon Wrappers</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.badges")}</h2>
{/* Badges */}
<div className="mb-6">
<h3 className="clay-title-h3 text-clay-text/60 mb-3">Badges (Pastel & Nút trạng thái)</h3>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">{t("styleGuide.badgeTypes")}</h3>
<div className="flex flex-wrap gap-2">
<Badge type="primary">Hệ thống</Badge>
<Badge type="income">Thu nhập (+)</Badge>
<Badge type="expense">Chi tiêu (-)</Badge>
<Badge type="warning">Vượt hạn mức</Badge>
<Badge type="info">Tin tức</Badge>
<Badge type="primary">{t("styleGuide.system")}</Badge>
<Badge type="income">{t("styleGuide.income")}</Badge>
<Badge type="expense">{t("styleGuide.expense")}</Badge>
<Badge type="warning">{t("styleGuide.overLimit")}</Badge>
<Badge type="info">{t("styleGuide.news")}</Badge>
</div>
</div>
{/* Icon Wrappers */}
<div>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">Icon Wrappers (Bọc icon danh mục)</h3>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">{t("styleGuide.iconWrappers")}</h3>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<IconWrapper type="income" size="sm">
<SavingGoalIcon size={18} />
</IconWrapper>
<span className="text-sm font-semibold text-clay-text">Small (40px) - Income</span>
<span className="text-sm font-semibold text-clay-text">{t("styleGuide.smallIncome")}</span>
</div>
<div className="flex items-center gap-4">
<IconWrapper type="expense" size="md">
<FoodIcon size={24} />
</IconWrapper>
<span className="text-sm font-semibold text-clay-text">Medium (48px) - Expense</span>
<span className="text-sm font-semibold text-clay-text">{t("styleGuide.mediumExpense")}</span>
</div>
<div className="flex items-center gap-4">
<IconWrapper type="primary" size="lg">
<AIAssistantIcon size={32} />
</IconWrapper>
<span className="text-sm font-semibold text-clay-text">Large (64px) - Primary</span>
<span className="text-sm font-semibold text-clay-text">{t("styleGuide.largePrimary")}</span>
</div>
</div>
</div>
......@@ -244,7 +246,7 @@ const StyleGuidePage: React.FC = () => {
{/* Section: Avatar */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">6. Avatar</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.avatar")}</h2>
<div className="flex items-center gap-4">
<div className="flex items-end gap-2">
<Avatar size="sm" />
......@@ -252,35 +254,35 @@ const StyleGuidePage: React.FC = () => {
<Avatar size="lg" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-clay-text">Avatar bo tròn</span>
<span className="clay-caption">Viền trắng dày 3px & Shadow nhẹ</span>
<span className="text-sm font-bold text-clay-text">{t("styleGuide.roundedAvatar")}</span>
<span className="clay-caption">{t("styleGuide.avatarHint")}</span>
</div>
</div>
</Card>
{/* Section: Tabs */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">7. Tabs (Chuyển tab)</h2>
<p className="clay-caption mb-3">Tabs được tích hợp rãnh trượt âm xuống đất sét, tab được chọn sẽ nổi hẳn lên.</p>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.tabsSection")}</h2>
<p className="clay-caption mb-3">{t("styleGuide.tabsHint")}</p>
<Tabs
tabs={tabItems}
activeTab={activeTab}
onChange={(key) => setActiveTab(key)}
/>
<div className="mt-3 p-3 bg-clay-bg rounded-clay-sm text-center text-sm text-clay-text shadow-clay-pressed">
Đang hiển thị nội dung của: <span className="font-bold text-clay-primary">{tabItems.find(t => t.key === activeTab)?.label}</span>
{t("styleGuide.showing")} <span className="font-bold text-clay-primary">{tabItems.find((tab) => tab.key === activeTab)?.label}</span>
</div>
</Card>
{/* Section: Progress Bar */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">8. Progress Bar</h2>
<p className="clay-caption mb-4">Dùng cho quản lý ngân sách & mục tiêu tiết kiệm. Điều chỉnh thanh trượt dưới để xem cập nhật trực quan.</p>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.progress")}</h2>
<p className="clay-caption mb-4">{t("styleGuide.progressHint")}</p>
<div className="flex flex-col gap-4">
{/* Interactive Control */}
<div className="flex items-center gap-3 mb-2">
<span className="text-xs font-semibold text-clay-text">Chỉnh tiến độ:</span>
<span className="text-xs font-semibold text-clay-text">{t("styleGuide.adjustProgress")}</span>
<input
type="range"
min="0"
......@@ -295,19 +297,19 @@ const StyleGuidePage: React.FC = () => {
{/* Variations */}
<div className="flex flex-col gap-3">
<div>
<span className="text-xs text-clay-text-muted">Primary (Ngân sách mặc định):</span>
<span className="text-xs text-clay-text-muted">{t("styleGuide.defaultBudget")}</span>
<ProgressBar value={progressVal} type="primary" />
</div>
<div>
<span className="text-xs text-clay-text-muted">Income (Tiết kiệm):</span>
<span className="text-xs text-clay-text-muted">{t("styleGuide.saving")}</span>
<ProgressBar value={progressVal} type="income" />
</div>
<div>
<span className="text-xs text-clay-text-muted">Expense (Đã chi):</span>
<span className="text-xs text-clay-text-muted">{t("styleGuide.spent")}</span>
<ProgressBar value={progressVal} type="expense" />
</div>
<div>
<span className="text-xs text-clay-text-muted">Warning (Cận hạn mức):</span>
<span className="text-xs text-clay-text-muted">{t("styleGuide.nearLimit")}</span>
<ProgressBar value={progressVal} type="warning" />
</div>
</div>
......@@ -316,85 +318,85 @@ const StyleGuidePage: React.FC = () => {
{/* Section: Modal & Sheet */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">9. Modal & Sheet</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.modal")}</h2>
<div className="text-center py-2">
<Button variant="primary" onClick={() => setIsModalOpen(true)}>
Mở Modal Thử Nghiệm
{t("styleGuide.openModal")}
</Button>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Thêm Mới Giao Dịch"
title={t("styleGuide.addTransaction")}
footer={
<>
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>Hủy</Button>
<Button variant="primary" onClick={() => setIsModalOpen(false)}>Lưu Giao Dịch</Button>
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>{t("common.cancel")}</Button>
<Button variant="primary" onClick={() => setIsModalOpen(false)}>{t("styleGuide.saveTransaction")}</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-clay-text-muted">
Modal thiết kế mềm mại, mờ đục hậu cảnh (backdrop blur), bo góc 32px rộng rãi và bóng đổ nổi bật.
{t("styleGuide.modalHint")}
</p>
<Input label="Tên giao dịch" placeholder="Nhập tên..." />
<Input label="Số tiền" type="number" placeholder="0" />
<Input label={t("styleGuide.transactionName")} placeholder={t("styleGuide.namePlaceholder")} />
<Input label={t("styleGuide.amount")} type="number" placeholder="0" />
</div>
</Modal>
</Card>
{/* Section: 3D Icons List */}
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">10. 3D SVG Icons (Bảng Icon)</h2>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">{t("styleGuide.icons")}</h2>
<div className="grid grid-cols-4 gap-4 text-center">
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<HomeIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Home</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.home")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<WalletIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold"></span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.wallet")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<TransactionIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Giao dịch</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.transaction")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<TransferIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Chuyển ví</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.transfer")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<BudgetIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Ngân sách</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.budget")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<SavingGoalIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Tiết kiệm</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.savings")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<ReportIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Báo cáo</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.report")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<AIAssistantIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Trợ lý AI</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.assistant")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<NotificationIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Thông báo</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.notification")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<UserIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Tài khoản</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.account")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<PlusIcon size={28} className="text-clay-primary" />
<span className="text-[10px] text-clay-text-muted font-semibold">Thêm mới</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.add")}</span>
</div>
<div className="flex flex-col items-center gap-1.5 p-2 bg-clay-bg rounded-clay-sm border border-clay-text/5">
<FoodIcon size={28} />
<span className="text-[10px] text-clay-text-muted font-semibold">Ăn uống</span>
<span className="text-[10px] text-clay-text-muted font-semibold">{t("styleGuide.food")}</span>
</div>
</div>
</Card>
......
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -8,39 +8,52 @@ import { Modal } from "@/components/ui/Modal";
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";
const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function formatBalanceInput(value: string): string {
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): string {
if (!value || value === "-") {
return value;
}
const isNegative = value.startsWith("-");
const [integerPart, decimalPart] = value.replace("-", "").split(".");
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${isNegative ? "-" : ""}${groupedInteger}${decimalPart !== undefined ? `,${decimalPart}` : ""}`;
return `${isNegative ? "-" : ""}${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseBalanceInput(value: string): string {
function parseBalanceInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const isNegative = trimmedValue.startsWith("-");
const unsignedValue = trimmedValue.replace(/-/g, "");
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(",")) {
[integerDisplay, decimalDisplay] = unsignedValue.split(",", 2);
} else if (/^\d+\.\d{0,2}$/.test(unsignedValue)) {
// Also accept a decimal point when users paste values such as 1200.50.
const separatorIndex = unsignedValue.lastIndexOf(".");
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);
......@@ -50,17 +63,17 @@ function parseBalanceInput(value: string): string {
return `${isNegative ? "-" : ""}${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const walletFormSchema = z.object({
name: z.string().trim().min(1, "Vui lòng nhập tên ví").max(100, "Tên ví tối đa 100 ký tự"),
balance: z.string().trim().regex(balancePattern, "Số dư có tối đa 16 chữ số và 2 số thập phân"),
currency: z.string().trim().length(3, "Mã tiền tệ gồm đúng 3 chữ cái").regex(/^[A-Za-z]{3}$/, "Mã tiền tệ chỉ gồm chữ cái"),
const createWalletFormSchema = (t: TranslationFunction) => z.object({
name: z.string().trim().min(1, t("validation.walletNameRequired")).max(100, t("validation.walletNameMax")),
balance: z.string().trim().regex(balancePattern, t("validation.balanceInvalid")),
currency: z.string().trim().length(3, t("validation.currencyLength")).regex(/^[A-Za-z]{3}$/, t("validation.currencyLetters")),
icon: z.string().min(1),
color: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/),
description: z.string().trim().max(500, "Mô tả tối đa 500 ký tự"),
description: z.string().trim().max(500, t("validation.descriptionMax")),
isDefault: z.boolean(),
});
type WalletFormValues = z.infer<typeof walletFormSchema>;
type WalletFormValues = z.infer<ReturnType<typeof createWalletFormSchema>>;
interface WalletFormModalProps {
isOpen: boolean;
......@@ -89,6 +102,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
onClose,
onSubmit,
}) => {
const { intlLocale, t } = useI18n();
const walletFormSchema = useMemo(() => createWalletFormSchema(t), [t]);
const formId = wallet ? `edit-wallet-${wallet.id}` : "create-wallet";
const {
control,
......@@ -128,14 +143,14 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<Modal
isOpen={isOpen}
onClose={onClose}
title={wallet ? "Chỉnh sửa ví" : "Tạo ví mới"}
title={wallet ? t("wallet.form.editTitle") : t("wallet.form.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting} className="text-sm px-4">
Hủy
{t("common.cancel")}
</Button>
<Button type="submit" form={formId} disabled={isSubmitting} className="text-sm px-4">
{isSubmitting ? "Đang lưu..." : wallet ? "Lưu thay đổi" : "Tạo ví"}
{isSubmitting ? t("common.saving") : wallet ? t("wallet.form.saveChanges") : t("wallet.create")}
</Button>
</>
)}
......@@ -144,12 +159,12 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<div className="flex items-center gap-3 rounded-clay bg-clay-bg p-3 shadow-clay-pressed">
<WalletArtwork icon={selectedIcon} color={selectedColor} />
<div>
<p className="font-baloo font-bold text-clay-text">Xem trước ví</p>
<p className="clay-caption">Chọn biểu tượng và màu nhận diện</p>
<p className="font-baloo font-bold text-clay-text">{t("wallet.form.preview")}</p>
<p className="clay-caption">{t("wallet.form.previewHint")}</p>
</div>
</div>
<Input label="Tên ví *" placeholder="Ví dụ: Tiền mặt" error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<Input label={t("wallet.form.name")} placeholder={t("wallet.form.namePlaceholder")} error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<div className="grid grid-cols-[1fr_96px] gap-3">
<Controller
......@@ -158,29 +173,29 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
render={({ field }) => (
<Input
{...field}
label="Số dư *"
label={t("wallet.form.balance")}
inputMode="decimal"
placeholder="0"
error={errors.balance?.message}
disabled={isSubmitting}
className="text-right tabular-nums"
value={formatBalanceInput(field.value)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value))}
value={formatBalanceInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale))}
/>
)}
/>
<Input label="Tiền tệ *" maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
<Input label={t("wallet.form.currency")} maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
</div>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">Biểu tượng</legend>
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("wallet.form.icon")}</legend>
<div className="grid grid-cols-5 gap-2">
{WALLET_ICONS.map((item) => (
<button
key={item.value}
type="button"
title={item.label}
aria-label={item.label}
title={t(item.labelKey)}
aria-label={t(item.labelKey)}
aria-pressed={selectedIcon === item.value}
className={`flex justify-center rounded-clay-sm p-2 transition-all duration-200 ease-in-out ${selectedIcon === item.value ? "bg-clay-primary/20 shadow-clay-pressed" : "bg-clay-bg shadow-clay-raised"}`}
onClick={() => setValue("icon", item.value, { shouldValidate: true })}
......@@ -192,13 +207,13 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
</fieldset>
<fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">Màu ví</legend>
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("wallet.form.color")}</legend>
<div className="flex gap-3 px-1">
{WALLET_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={`Chọn màu ${color}`}
aria-label={t("wallet.form.chooseColor", { color })}
aria-pressed={selectedColor === color}
className={`h-9 w-9 rounded-full border-2 transition-all duration-200 ease-in-out ${selectedColor === color ? "scale-110 border-clay-text shadow-clay-raised" : "border-clay-highlight/70"}`}
style={{ backgroundColor: color }}
......@@ -209,11 +224,11 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
</fieldset>
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">Mô tả</label>
<label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("wallet.form.description")}</label>
<textarea
id={`${formId}-description`}
rows={3}
placeholder="Mục đích sử dụng ví..."
placeholder={t("wallet.form.descriptionPlaceholder")}
disabled={isSubmitting}
className={`w-full resize-none rounded-clay-sm border bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder:text-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 ${errors.description ? "border-clay-expense" : "border-transparent"}`}
{...register("description")}
......@@ -225,8 +240,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<label className="flex cursor-pointer items-start gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<input type="checkbox" className="mt-1 h-4 w-4 accent-clay-primary" disabled={isSubmitting} {...register("isDefault")} />
<span>
<span className="block font-nunito text-sm font-bold text-clay-text">Đặt làm ví mặc định</span>
<span className="clay-caption block">Ví đầu tiên luôn tự động trở thành ví mặc định.</span>
<span className="block font-nunito text-sm font-bold text-clay-text">{t("wallet.form.defaultTitle")}</span>
<span className="clay-caption block">{t("wallet.form.defaultHint")}</span>
</span>
</label>
)}
......
......@@ -18,22 +18,14 @@ import { formatWalletBalance } from "@/lib/wallet-format";
import { WalletInput } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton";
function formatDate(value: string): string {
return new Intl.DateTimeFormat("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
import { useI18n } from "@/i18n";
const WalletDetailPage: React.FC = () => {
const params = useParams<{ id: string }>();
const walletId = params.id || "";
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { formatDate, intlLocale, t } = useI18n();
const [isEditOpen, setIsEditOpen] = useState(false);
const [isArchiveOpen, setIsArchiveOpen] = useState(false);
const walletQuery = useWallet(walletId);
......@@ -52,16 +44,16 @@ const WalletDetailPage: React.FC = () => {
updateMutation.mutate(updateInput, {
onSuccess: () => {
setIsEditOpen(false);
openSnackbar({ type: "success", text: "Đã lưu thay đổi của ví." });
openSnackbar({ type: "success", text: t("wallet.updateSuccess") });
},
onError: (error) => showError(error, "Không thể cập nhật ví."),
onError: (error) => showError(error, t("wallet.updateFailed")),
});
};
const handleSetDefault = () => {
setDefaultMutation.mutate(walletId, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã đặt làm ví mặc định." }),
onError: (error) => showError(error, "Không thể đặt ví mặc định."),
onSuccess: () => openSnackbar({ type: "success", text: t("wallet.defaultSet") }),
onError: (error) => showError(error, t("wallet.defaultFailed")),
});
};
......@@ -69,22 +61,22 @@ const WalletDetailPage: React.FC = () => {
archiveMutation.mutate(walletId, {
onSuccess: () => {
setIsArchiveOpen(false);
openSnackbar({ type: "success", text: "Ví đã được lưu trữ; lịch sử giao dịch vẫn được giữ nguyên." });
openSnackbar({ type: "success", text: t("wallet.archiveSuccess") });
},
onError: (error) => showError(error, "Không thể lưu trữ ví."),
onError: (error) => showError(error, t("wallet.archiveFailed")),
});
};
const handleRestore = () => {
restoreMutation.mutate(walletId, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã khôi phục ví thành công." }),
onError: (error) => showError(error, "Không thể khôi phục ví."),
onSuccess: () => openSnackbar({ type: "success", text: t("wallet.restoreSuccess") }),
onError: (error) => showError(error, t("wallet.restoreFailed")),
});
};
return (
<Page className="page">
<Header title="Chi tiết ví" showBackIcon onBackClick={() => navigate("/wallets")} />
<Header title={t("wallet.detailHeader")} showBackIcon onBackClick={() => navigate("/wallets")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......@@ -94,12 +86,12 @@ const WalletDetailPage: React.FC = () => {
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div>
<div>
<h1 className="clay-title-h3">Không thể tải thông tin ví</h1>
<p className="clay-caption mt-1">{getErrorMessage(walletQuery.error, "Ví không tồn tại hoặc kết nối bị gián đoạn.")}</p>
<h1 className="clay-title-h3">{t("wallet.detailLoadFailed")}</h1>
<p className="clay-caption mt-1">{getErrorMessage(walletQuery.error, t("wallet.missing"))}</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" className="text-sm" onClick={() => navigate("/wallets")}>Danh sách ví</Button>
<Button variant="secondary" className="text-sm" onClick={() => walletQuery.refetch()}>Thử lại</Button>
<Button variant="ghost" className="text-sm" onClick={() => navigate("/wallets")}>{t("wallet.listButton")}</Button>
<Button variant="secondary" className="text-sm" onClick={() => walletQuery.refetch()}>{t("common.retry")}</Button>
</div>
</Card>
)}
......@@ -112,11 +104,11 @@ const WalletDetailPage: React.FC = () => {
<WalletArtwork icon={wallet.icon} color={wallet.color} size="lg" archived={wallet.isArchived} />
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<h1 className="clay-title-h2">{wallet.name}</h1>
{wallet.isDefault && <Badge type="primary">Ví mặc định</Badge>}
{wallet.isArchived && <Badge type="warning">Đã lưu trữ</Badge>}
{wallet.isDefault && <Badge type="primary">{t("wallet.defaultWallet")}</Badge>}
{wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div>
<p className={`mt-2 font-baloo text-3xl font-bold ${Number(wallet.balance) < 0 ? "text-clay-expense" : "text-clay-primary-dark"}`}>
{formatWalletBalance(wallet.balance, wallet.currency)}
{formatWalletBalance(wallet.balance, wallet.currency, intlLocale)}
</p>
<p className="mt-1 font-nunito text-xs font-bold uppercase tracking-wider text-clay-text-muted">{wallet.currency}</p>
{wallet.description && <p className="clay-body mt-4 max-w-sm text-sm">{wallet.description}</p>}
......@@ -124,31 +116,31 @@ const WalletDetailPage: React.FC = () => {
</Card>
<Card className="p-5">
<h2 className="clay-title-h3 mb-4">Thông tin ví</h2>
<h2 className="clay-title-h3 mb-4">{t("wallet.info")}</h2>
<dl className="divide-y divide-clay-text-muted/10">
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Trạng thái</dt>
<dd className="font-nunito text-sm font-bold text-clay-text">{wallet.isArchived ? "Đã lưu trữ" : "Đang hoạt động"}</dd>
<dt className="clay-caption">{t("wallet.status")}</dt>
<dd className="font-nunito text-sm font-bold text-clay-text">{wallet.isArchived ? t("common.archived") : t("common.active")}</dd>
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Ngày tạo</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.createdAt)}</dd>
<dt className="clay-caption">{t("wallet.createdAt")}</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.createdAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">Cập nhật gần nhất</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.updatedAt)}</dd>
<dt className="clay-caption">{t("wallet.updatedAt")}</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.updatedAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</div>
</dl>
</Card>
<Card className="flex flex-col gap-3 p-5">
<h2 className="clay-title-h3">Quản lý ví</h2>
<h2 className="clay-title-h3">{t("wallet.management")}</h2>
{!wallet.isArchived && (
<>
<Button fullWidth onClick={() => setIsEditOpen(true)}>Chỉnh sửa thông tin</Button>
<Button fullWidth onClick={() => setIsEditOpen(true)}>{t("wallet.edit")}</Button>
{!wallet.isDefault && (
<Button variant="secondary" fullWidth disabled={setDefaultMutation.isPending} onClick={handleSetDefault}>
{setDefaultMutation.isPending ? "Đang cập nhật..." : "Đặt làm ví mặc định"}
{setDefaultMutation.isPending ? t("common.updating") : t("wallet.setDefault")}
</Button>
)}
<Button
......@@ -158,19 +150,19 @@ const WalletDetailPage: React.FC = () => {
onClick={() => setIsArchiveOpen(true)}
className="text-clay-expense"
>
Lưu trữ ví
{t("wallet.archive")}
</Button>
{wallet.isDefault && (
<p className="clay-caption text-center">Hãy đặt một ví khác làm mặc định trước khi lưu trữ ví này.</p>
<p className="clay-caption text-center">{t("wallet.archiveDefaultHint")}</p>
)}
</>
)}
{wallet.isArchived && (
<>
<Button fullWidth disabled={restoreMutation.isPending} onClick={handleRestore}>
{restoreMutation.isPending ? "Đang khôi phục..." : "Khôi phục ví"}
{restoreMutation.isPending ? t("wallet.restoring") : t("wallet.restore")}
</Button>
<p className="clay-caption text-center">Nếu chưa có ví hoạt động mặc định, ví này sẽ tự động được chọn sau khi khôi phục.</p>
<p className="clay-caption text-center">{t("wallet.restoreHint")}</p>
</>
)}
</Card>
......@@ -191,17 +183,17 @@ const WalletDetailPage: React.FC = () => {
<Modal
isOpen={isArchiveOpen}
onClose={() => setIsArchiveOpen(false)}
title="Lưu trữ ví?"
title={t("wallet.archiveTitle")}
footer={(
<>
<Button variant="ghost" className="text-sm" disabled={archiveMutation.isPending} onClick={() => setIsArchiveOpen(false)}>Hủy</Button>
<Button variant="ghost" className="text-sm" disabled={archiveMutation.isPending} onClick={() => setIsArchiveOpen(false)}>{t("common.cancel")}</Button>
<Button className="bg-clay-expense text-sm" disabled={archiveMutation.isPending} onClick={handleArchive}>
{archiveMutation.isPending ? "Đang lưu trữ..." : "Xác nhận lưu trữ"}
{archiveMutation.isPending ? t("wallet.archiving") : t("wallet.archiveConfirm")}
</Button>
</>
)}
>
<p className="clay-body text-sm">Ví sẽ không còn dùng được cho giao dịch mới, nhưng toàn bộ lịch sử giao dịch và số dư vẫn được bảo toàn. Bạn có thể khôi phục ví bất cứ lúc nào.</p>
<p className="clay-body text-sm">{t("wallet.archiveDescription")}</p>
</Modal>
</Page>
);
......
......@@ -12,27 +12,28 @@ import { formatWalletBalance, groupBalancesByCurrency } from "@/lib/wallet-forma
import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton";
import { useI18n } from "@/i18n";
const PAGE_SIZE = 6;
const sortOptions = [
{ value: "createdAt:desc", label: "Mới tạo gần đây" },
{ value: "updatedAt:desc", label: "Mới cập nhật" },
{ value: "name:asc", label: "Tên A → Z" },
{ value: "name:desc", label: "Tên Z → A" },
{ value: "balance:desc", label: "Số dư cao nhất" },
{ value: "balance:asc", label: "Số dư thấp nhất" },
];
const WalletsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase("vi"));
const { intlLocale, t } = useI18n();
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase(intlLocale));
const [includeArchived, setIncludeArchived] = useState(false);
const [sort, setSort] = useState("createdAt:desc");
const [page, setPage] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const sortOptions = useMemo(() => [
{ value: "createdAt:desc", label: t("wallet.sort.createdDesc") },
{ value: "updatedAt:desc", label: t("wallet.sort.updatedDesc") },
{ value: "name:asc", label: t("wallet.sort.nameAsc") },
{ value: "name:desc", label: t("wallet.sort.nameDesc") },
{ value: "balance:desc", label: t("wallet.sort.balanceDesc") },
{ value: "balance:asc", label: t("wallet.sort.balanceAsc") },
], [t]);
const [sortBy, order] = sort.split(":") as [WalletSortField, SortOrder];
const query = useMemo<WalletQuery>(() => ({
......@@ -72,8 +73,8 @@ const WalletsPage: React.FC = () => {
}
return wallets.filter((wallet) => [wallet.name, wallet.description || "", wallet.currency]
.some((value) => value.toLocaleLowerCase("vi").includes(deferredSearch)));
}, [deferredSearch, walletsQuery.data?.data]);
.some((value) => value.toLocaleLowerCase(intlLocale).includes(deferredSearch)));
}, [deferredSearch, intlLocale, walletsQuery.data?.data]);
const visibleWallets = deferredSearch
? filteredWallets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
......@@ -97,24 +98,24 @@ const WalletsPage: React.FC = () => {
createMutation.mutate(input, {
onSuccess: () => {
setIsCreateOpen(false);
openSnackbar({ type: "success", text: "Đã tạo ví mới thành công." });
openSnackbar({ type: "success", text: t("wallet.createSuccess") });
},
onError: (error) => {
openSnackbar({ type: "error", text: getErrorMessage(error, "Không thể tạo ví. Vui lòng thử lại.") });
openSnackbar({ type: "error", text: getErrorMessage(error, t("wallet.createFailed")) });
},
});
};
const handleSetDefault = (id: string) => {
setDefaultMutation.mutate(id, {
onSuccess: () => openSnackbar({ type: "success", text: "Đã cập nhật ví mặc định." }),
onError: (error) => openSnackbar({ type: "error", text: getErrorMessage(error, "Không thể đặt ví mặc định.") }),
onSuccess: () => openSnackbar({ type: "success", text: t("wallet.defaultUpdated") }),
onError: (error) => openSnackbar({ type: "error", text: getErrorMessage(error, t("wallet.defaultFailed")) }),
});
};
return (
<Page className="page">
<Header title="Ví của tôi" showBackIcon onBackClick={() => navigate("/")} />
<Header title={t("wallet.header")} showBackIcon onBackClick={() => navigate("/")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......@@ -123,21 +124,21 @@ const WalletsPage: React.FC = () => {
<div className="absolute -bottom-9 right-16 h-24 w-24 rounded-full bg-clay-primary-dark/25" />
<div className="relative flex items-start justify-between gap-3">
<div>
<p className="font-nunito text-sm font-bold text-clay-on-primary">Tổng số dư theo tiền tệ</p>
<p className="font-nunito text-sm font-bold text-clay-on-primary">{t("wallet.summary")}</p>
{summary.isLoading ? (
<div className="mt-3 h-8 w-40 animate-pulse rounded-full bg-clay-on-primary/20" />
) : balances.length > 0 ? (
<div className="mt-2 flex flex-col gap-1">
{balances.map((item) => (
<p key={item.currency} className="font-baloo text-2xl font-bold text-clay-on-primary">
{formatWalletBalance(item.balance, item.currency)}
{formatWalletBalance(item.balance, item.currency, intlLocale)}
</p>
))}
</div>
) : (
<p className="mt-2 font-baloo text-2xl font-bold text-clay-on-primary">Chưa có số dư</p>
<p className="mt-2 font-baloo text-2xl font-bold text-clay-on-primary">{t("wallet.noBalance")}</p>
)}
<p className="mt-2 font-nunito text-xs font-semibold text-clay-on-primary">{activeWalletCount} ví đang hoạt động</p>
<p className="mt-2 font-nunito text-xs font-semibold text-clay-on-primary">{t("wallet.activeCount", { count: activeWalletCount })}</p>
</div>
<div className="rounded-clay bg-clay-on-primary/20 p-3 shadow-clay-pressed"><WalletIcon size={30} /></div>
</div>
......@@ -145,11 +146,11 @@ const WalletsPage: React.FC = () => {
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="clay-title-h2">Danh sách ví</h1>
<p className="clay-caption">{walletsQuery.isFetching ? "Đang đồng bộ..." : `${totalItems} kết quả`}</p>
<h1 className="clay-title-h2">{t("wallet.list")}</h1>
<p className="clay-caption">{walletsQuery.isFetching ? t("wallet.syncing") : t("common.results", { count: totalItems })}</p>
</div>
<Button shape="pill" className="gap-2 px-4 text-sm" onClick={() => setIsCreateOpen(true)}>
<PlusIcon size={18} /> Tạo ví
<PlusIcon size={18} /> {t("wallet.create")}
</Button>
</div>
......@@ -159,24 +160,24 @@ const WalletsPage: React.FC = () => {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></svg>
</span>
<Input
aria-label="Tìm kiếm ví"
placeholder="Tìm theo tên, mô tả, tiền tệ..."
aria-label={t("wallet.searchLabel")}
placeholder={t("wallet.searchPlaceholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-11"
/>
</div>
<div className="grid grid-cols-[1fr_auto] items-end gap-3">
<Select aria-label="Sắp xếp ví" options={sortOptions} value={sort} onChange={(event) => setSort(event.target.value)} />
<Select aria-label={t("wallet.sortLabel")} options={sortOptions} value={sort} onChange={(event) => setSort(event.target.value)} />
<label className="flex h-[50px] cursor-pointer items-center gap-2 rounded-clay-sm bg-clay-bg px-3 shadow-clay-pressed transition-all duration-200 ease-in-out">
<input type="checkbox" checked={includeArchived} onChange={(event) => setIncludeArchived(event.target.checked)} className="h-4 w-4 accent-clay-primary" />
<span className="whitespace-nowrap font-nunito text-xs font-bold text-clay-text">Đã lưu trữ</span>
<span className="whitespace-nowrap font-nunito text-xs font-bold text-clay-text">{t("wallet.includeArchived")}</span>
</label>
</div>
</Card>
{walletsQuery.isLoading && (
<div className="flex flex-col gap-4" aria-label="Đang tải danh sách ví">
<div className="flex flex-col gap-4" aria-label={t("wallet.loadingList")}>
{Array.from({ length: 3 }, (_, index) => <WalletSkeleton key={index} />)}
</div>
)}
......@@ -185,10 +186,10 @@ const WalletsPage: React.FC = () => {
<Card className="flex flex-col items-center gap-3 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 text-clay-expense shadow-clay-pressed">!</div>
<div>
<h2 className="clay-title-h3">Không thể tải danh sách ví</h2>
<p className="clay-caption mt-1">{getErrorMessage(walletsQuery.error, "Vui lòng kiểm tra kết nối và thử lại.")}</p>
<h2 className="clay-title-h3">{t("wallet.loadFailed")}</h2>
<p className="clay-caption mt-1">{getErrorMessage(walletsQuery.error, t("wallet.connectionFailed"))}</p>
</div>
<Button variant="secondary" className="text-sm" onClick={() => walletsQuery.refetch()}>Thử lại</Button>
<Button variant="secondary" className="text-sm" onClick={() => walletsQuery.refetch()}>{t("common.retry")}</Button>
</Card>
)}
......@@ -196,10 +197,10 @@ const WalletsPage: React.FC = () => {
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="rounded-clay-lg bg-clay-info/15 p-4 shadow-clay-pressed"><WalletIcon size={42} /></div>
<div>
<h2 className="clay-title-h3">{deferredSearch ? "Không tìm thấy ví phù hợp" : includeArchived ? "Chưa có ví nào" : "Bắt đầu với ví đầu tiên"}</h2>
<p className="clay-caption mt-1 max-w-xs">{deferredSearch ? "Thử một từ khóa khác hoặc bật danh sách đã lưu trữ." : "Tạo ví để theo dõi số dư và quản lý tài chính của bạn."}</p>
<h2 className="clay-title-h3">{deferredSearch ? t("wallet.notFound") : includeArchived ? t("wallet.empty") : t("wallet.first")}</h2>
<p className="clay-caption mt-1 max-w-xs">{deferredSearch ? t("wallet.searchHint") : t("wallet.emptyHint")}</p>
</div>
{!deferredSearch && <Button className="gap-2 text-sm" onClick={() => setIsCreateOpen(true)}><PlusIcon size={18} /> Tạo ví mới</Button>}
{!deferredSearch && <Button className="gap-2 text-sm" onClick={() => setIsCreateOpen(true)}><PlusIcon size={18} /> {t("wallet.createNew")}</Button>}
</Card>
)}
......@@ -218,10 +219,10 @@ const WalletsPage: React.FC = () => {
)}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3" aria-label="Phân trang ví">
<Button variant="secondary" className="px-4 text-sm" disabled={page <= 1 || walletsQuery.isFetching} onClick={() => setPage((current) => current - 1)}>Trước</Button>
<span className="rounded-full bg-clay-surface px-4 py-2 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed">{page} / {totalPages}</span>
<Button variant="secondary" className="px-4 text-sm" disabled={page >= totalPages || walletsQuery.isFetching} onClick={() => setPage((current) => current + 1)}>Sau</Button>
<nav className="flex items-center justify-between gap-3" aria-label={t("wallet.paginationLabel")}>
<Button variant="secondary" className="px-4 text-sm" disabled={page <= 1 || walletsQuery.isFetching} onClick={() => setPage((current) => current - 1)}>{t("common.previous")}</Button>
<span className="rounded-full bg-clay-surface px-4 py-2 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed">{t("common.pageOf", { page, total: totalPages })}</span>
<Button variant="secondary" className="px-4 text-sm" disabled={page >= totalPages || walletsQuery.isFetching} onClick={() => setPage((current) => current + 1)}>{t("common.next")}</Button>
</nav>
)}
</main>
......
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