Commit 52e200d9 authored by ThinhNC's avatar ThinhNC

feat(i18n): add Vietnamese and English localization

parent ce98635f
......@@ -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;
}
This diff is collapsed.
This diff is collapsed.
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>
);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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