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 ...@@ -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. - **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. - **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. - **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`. - **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. - **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). - **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. - **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. - **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": { "app": {
"title": "FinWise - Sổ tay chi tiêu & Báo cáo tài chính", "title": "FinWise",
"textColor": { "textColor": {
"light": "black", "light": "black",
"dark": "white" "dark": "white"
......
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Text } from "zmp-ui"; import { Text } from "zmp-ui";
import { useI18n } from "@/i18n";
function Clock() { function Clock() {
const { intlLocale } = useI18n();
const [time, setTime] = useState(""); const [time, setTime] = useState("");
useEffect(() => { useEffect(() => {
const updateClock = () => { const updateClock = () => {
const now = new Date(); const now = new Date();
const formattedTime = now.toLocaleString("vi-VN", { const formattedTime = now.toLocaleString(intlLocale, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
...@@ -21,7 +23,7 @@ function Clock() { ...@@ -21,7 +23,7 @@ function Clock() {
updateClock(); updateClock();
const intervalId = setInterval(updateClock, 1000); const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, [intlLocale]);
return <Text className="font-mono">{time}</Text>; 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"; ...@@ -14,6 +14,8 @@ import { AuthGuard } from "@/components/shared/AuthGuard";
import { DocumentTitle } from "@/components/shared/DocumentTitle"; import { DocumentTitle } from "@/components/shared/DocumentTitle";
import { ThemeControl } from "@/components/shared/ThemeControl"; import { ThemeControl } from "@/components/shared/ThemeControl";
import { applyTheme, useThemeStore } from "@/stores/theme-store"; import { applyTheme, useThemeStore } from "@/stores/theme-store";
import { I18nProvider } from "@/i18n";
import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher";
import HomePage from "@/pages/index"; import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide"; import StyleGuidePage from "@/pages/style-guide";
...@@ -59,32 +61,39 @@ const Layout = () => { ...@@ -59,32 +61,39 @@ const Layout = () => {
}, [theme]); }, [theme]);
return ( return (
<App theme={theme}> <I18nProvider>
<QueryClientProvider client={queryClient}> <App theme={theme}>
<SnackbarProvider> <QueryClientProvider client={queryClient}>
<ThemeControl /> <SnackbarProvider>
<ZMPRouter> <div
<DocumentTitle /> className="finwise-header-controls fixed z-[1001] flex items-center gap-2"
<AuthInitializer> >
<AnimationRoutes> <ThemeControl />
{/* Public Auth Routes */} <LanguageSwitcher />
<Route path="/login" element={<LoginPage />}></Route> </div>
<Route path="/register" element={<RegisterPage />}></Route> <ZMPRouter>
<Route path="/forgot-password" element={<ForgotPasswordPage />}></Route> <DocumentTitle />
<Route path="/reset-password" element={<ResetPasswordPage />}></Route> <AuthInitializer>
<AnimationRoutes>
{/* Public Auth Routes */}
<Route path="/login" element={<LoginPage />}></Route>
<Route path="/register" element={<RegisterPage />}></Route>
<Route path="/forgot-password" element={<ForgotPasswordPage />}></Route>
<Route path="/reset-password" element={<ResetPasswordPage />}></Route>
{/* Private Protected Routes */} {/* Private Protected Routes */}
<Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route> <Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route>
<Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route> <Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route>
<Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route> <Route path="/wallets" element={<AuthGuard><WalletsPage /></AuthGuard>}></Route>
<Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route> <Route path="/wallets/:id" element={<AuthGuard><WalletDetailPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
</ZMPRouter> </ZMPRouter>
</SnackbarProvider> </SnackbarProvider>
</QueryClientProvider> </QueryClientProvider>
</App> </App>
</I18nProvider>
); );
}; };
export default Layout; export default Layout;
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "zmp-ui"; import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useI18n } from "@/i18n";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore(); const { isAuthenticated, isInitialized } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n();
useEffect(() => { useEffect(() => {
if (isInitialized && !isAuthenticated) { if (isInitialized && !isAuthenticated) {
...@@ -16,7 +18,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) ...@@ -16,7 +18,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
return ( return (
<div className="page flex flex-col items-center justify-center min-h-screen"> <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> <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> </div>
); );
} }
......
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useLocation } from "zmp-ui"; import { useLocation } from "zmp-ui";
import { useI18n } from "@/i18n";
const APP_NAME = "FinWise"; 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<{ const PAGE_TITLES: ReadonlyArray<{
matches: (pathname: string) => boolean; matches: (pathname: string) => boolean;
title: string; key: string;
}> = [ }> = [
{ matches: (pathname) => pathname === "/", title: "Trang chủ" }, { matches: (pathname) => pathname === "/", key: "document.home" },
{ matches: (pathname) => pathname === "/login", title: "Đăng nhập" }, { matches: (pathname) => pathname === "/login", key: "document.login" },
{ matches: (pathname) => pathname === "/register", title: "Đăng ký" }, { matches: (pathname) => pathname === "/register", key: "document.register" },
{ matches: (pathname) => pathname === "/forgot-password", title: "Quên mật khẩu" }, { matches: (pathname) => pathname === "/forgot-password", key: "document.forgotPassword" },
{ matches: (pathname) => pathname === "/reset-password", title: "Đặt lại mật khẩu" }, { matches: (pathname) => pathname === "/reset-password", key: "document.resetPassword" },
{ matches: (pathname) => pathname === "/profile", title: "Tài khoản" }, { matches: (pathname) => pathname === "/profile", key: "document.profile" },
{ matches: (pathname) => pathname === "/wallets", title: "Ví của tôi" }, { matches: (pathname) => pathname === "/wallets", key: "document.wallets" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), title: "Chi tiết ví" }, { matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" },
{ matches: (pathname) => pathname === "/style-guide", title: "Style Guide" }, { 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 = () => { export const DocumentTitle: React.FC = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { t } = useI18n();
useEffect(() => { useEffect(() => {
document.title = getDocumentTitle(pathname); const key = PAGE_TITLES.find((page) => page.matches(pathname))?.key;
}, [pathname]); document.title = `${key ? t(key) : t("document.default")} | ${APP_NAME}`;
}, [pathname, t]);
return null; return null;
}; };
...@@ -5,15 +5,5 @@ import { useThemeStore } from "@/stores/theme-store"; ...@@ -5,15 +5,5 @@ import { useThemeStore } from "@/stores/theme-store";
export const ThemeControl: React.FC = () => { export const ThemeControl: React.FC = () => {
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
return ( return <ThemeToggle theme={theme} onChange={setTheme} variant="compact" />;
<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>
);
}; };
...@@ -5,6 +5,7 @@ import { ChevronRightIcon } from "@/components/ui/icons"; ...@@ -5,6 +5,7 @@ import { ChevronRightIcon } from "@/components/ui/icons";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
import { Wallet } from "@/types/wallet"; import { Wallet } from "@/types/wallet";
import { WalletArtwork } from "./WalletArtwork"; import { WalletArtwork } from "./WalletArtwork";
import { useI18n } from "@/i18n";
interface WalletCardProps { interface WalletCardProps {
wallet: Wallet; wallet: Wallet;
...@@ -18,7 +19,10 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -18,7 +19,10 @@ export const WalletCard: React.FC<WalletCardProps> = ({
onClick, onClick,
onSetDefault, onSetDefault,
isSettingDefault = false, isSettingDefault = false,
}) => ( }) => {
const { intlLocale, t } = useI18n();
return (
<Card <Card
hoverable hoverable
className={`p-4 ${wallet.isArchived ? "opacity-75" : ""}`} className={`p-4 ${wallet.isArchived ? "opacity-75" : ""}`}
...@@ -36,11 +40,11 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -36,11 +40,11 @@ export const WalletCard: React.FC<WalletCardProps> = ({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3> <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.isDefault && <Badge type="primary">{t("common.default")}</Badge>}
{wallet.isArchived && <Badge type="warning">Đã lưu trữ</Badge>} {wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div> </div>
<p className="font-baloo text-xl font-bold text-clay-primary-dark truncate"> <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> </p>
{wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>} {wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>}
</div> </div>
...@@ -57,8 +61,9 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -57,8 +61,9 @@ export const WalletCard: React.FC<WalletCardProps> = ({
onSetDefault(); onSetDefault();
}} }}
> >
{isSettingDefault ? "Đang cập nhật..." : "Đặt làm ví mặc định"} {isSettingDefault ? t("common.updating") : t("wallet.setDefault")}
</button> </button>
)} )}
</Card> </Card>
); );
};
import React from "react"; import React from "react";
import { useI18n } from "@/i18n";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> { export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
...@@ -10,12 +11,13 @@ export const Avatar: React.FC<AvatarProps> = ({ ...@@ -10,12 +11,13 @@ export const Avatar: React.FC<AvatarProps> = ({
size = "md", size = "md",
className = "", className = "",
src, src,
alt = "User Avatar", alt,
positionX = 50, positionX = 50,
positionY = 50, positionY = 50,
style, style,
...props ...props
}) => { }) => {
const { t } = useI18n();
const sizeStyles = { const sizeStyles = {
sm: "w-10 h-10 border-2", sm: "w-10 h-10 border-2",
md: "w-14 h-14 border-[3px]", md: "w-14 h-14 border-[3px]",
...@@ -34,7 +36,7 @@ export const Avatar: React.FC<AvatarProps> = ({ ...@@ -34,7 +36,7 @@ export const Avatar: React.FC<AvatarProps> = ({
> >
<img <img
src={src || defaultAvatar} src={src || defaultAvatar}
alt={alt} alt={alt || t("accessibility.userAvatar")}
className="w-full h-full object-cover rounded-full" className="w-full h-full object-cover rounded-full"
style={{ style={{
...style, ...style,
......
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { Button } from "./Button"; import { Button } from "./Button";
import { useI18n } from "@/i18n";
export interface ModalProps { export interface ModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -17,6 +18,7 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -17,6 +18,7 @@ export const Modal: React.FC<ModalProps> = ({
children, children,
footer, footer,
}) => { }) => {
const { t } = useI18n();
// Prevent background scrolling when open // Prevent background scrolling when open
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
...@@ -52,7 +54,9 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -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"> <div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3> <h3 className="clay-title-h3">{title}</h3>
<button <button
type="button"
onClick={onClose} 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]" 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} /> <CloseIcon size={16} />
......
import React from "react"; import React from "react";
import { ThemeMode } from "@/stores/theme-store"; import { ThemeMode } from "@/stores/theme-store";
import { useI18n } from "@/i18n";
export interface ThemeToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> { export interface ThemeToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
theme: ThemeMode; theme: ThemeMode;
...@@ -28,8 +29,9 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({ ...@@ -28,8 +29,9 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
onClick, onClick,
...props ...props
}) => { }) => {
const { t } = useI18n();
const isDark = theme === "dark"; 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>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(isDark ? "light" : "dark"); onChange(isDark ? "light" : "dark");
onClick?.(event); onClick?.(event);
...@@ -41,8 +43,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({ ...@@ -41,8 +43,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
type="button" type="button"
role="switch" role="switch"
aria-checked={isDark} aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`} aria-label={nextThemeLabel}
title={`Chuyển sang ${nextThemeLabel}`} title={nextThemeLabel}
{...props} {...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}`} 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} onClick={handleClick}
...@@ -57,8 +59,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({ ...@@ -57,8 +59,8 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
type="button" type="button"
role="switch" role="switch"
aria-checked={isDark} aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`} aria-label={nextThemeLabel}
title={`Chuyển sang ${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}`} 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} {...props}
onClick={handleClick} onClick={handleClick}
......
...@@ -36,10 +36,17 @@ ...@@ -36,10 +36,17 @@
--color-clay-badge-info-bg: 239 246 255; --color-clay-badge-info-bg: 239 246 255;
--color-clay-badge-info-text: 37 99 235; --color-clay-badge-info-text: 37 99 235;
--color-clay-badge-info-border: 219 234 254; --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-raised:
--shadow-clay-hover: 10px 10px 20px rgb(163 150 208 / 0.45), -8px -8px 16px rgb(255 255 255 / 0.85); 8px 8px 16px rgb(163 150 208 / 0.45), -6px -6px 14px 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-hover:
--shadow-clay-modal: 15px 15px 30px rgb(163 150 208 / 0.55), -10px -10px 20px rgb(255 255 255 / 0.9); 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); --shadow-clay-progress: inset -2px -2px 4px rgb(45 42 69 / 0.15);
} }
...@@ -80,10 +87,15 @@ ...@@ -80,10 +87,15 @@
--color-clay-badge-info-bg: 31 58 91; --color-clay-badge-info-bg: 31 58 91;
--color-clay-badge-info-text: 173 211 255; --color-clay-badge-info-text: 173 211 255;
--color-clay-badge-info-border: 53 91 137; --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-raised:
--shadow-clay-hover: 10px 10px 20px rgb(5 4 14 / 0.75), -8px -8px 16px rgb(83 76 112 / 0.4); 8px 8px 16px rgb(5 4 14 / 0.68), -6px -6px 14px rgb(78 72 105 / 0.35);
--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-hover:
--shadow-clay-modal: 15px 15px 30px rgb(4 3 11 / 0.78), -10px -10px 20px rgb(82 75 111 / 0.32); 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); --shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45);
} }
...@@ -95,24 +107,31 @@ body, ...@@ -95,24 +107,31 @@ body,
} }
body { body {
font-family: 'Nunito', sans-serif; font-family: "Nunito", sans-serif;
margin: 0; margin: 0;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased; -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 */ /* Custom style override for ZMP Page containers */
.page { .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)); background-color: rgb(var(--color-clay-bg));
min-height: 100vh; min-height: 100vh;
color: rgb(var(--color-clay-text)); 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 { .clay-title-h1 {
font-family: 'Baloo 2', sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 700; font-weight: 700;
font-size: 32px; font-size: 32px;
line-height: 1.2; line-height: 1.2;
...@@ -120,7 +139,7 @@ body { ...@@ -120,7 +139,7 @@ body {
} }
.clay-title-h2 { .clay-title-h2 {
font-family: 'Baloo 2', sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 600; font-weight: 600;
font-size: 24px; font-size: 24px;
line-height: 1.3; line-height: 1.3;
...@@ -128,7 +147,7 @@ body { ...@@ -128,7 +147,7 @@ body {
} }
.clay-title-h3 { .clay-title-h3 {
font-family: 'Baloo 2', sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 600; font-weight: 600;
font-size: 18px; font-size: 18px;
line-height: 1.4; line-height: 1.4;
...@@ -136,7 +155,7 @@ body { ...@@ -136,7 +155,7 @@ body {
} }
.clay-body { .clay-body {
font-family: 'Nunito', sans-serif; font-family: "Nunito", sans-serif;
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
...@@ -144,7 +163,7 @@ body { ...@@ -144,7 +163,7 @@ body {
} }
.clay-caption { .clay-caption {
font-family: 'Nunito', sans-serif; font-family: "Nunito", sans-serif;
font-weight: 500; font-weight: 500;
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
...@@ -172,10 +191,29 @@ body { ...@@ -172,10 +191,29 @@ body {
.zaui-page { .zaui-page {
background-color: rgb(var(--color-clay-bg)); background-color: rgb(var(--color-clay-bg));
color: rgb(var(--color-clay-text)); 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-title,
.zaui-header .zaui-header-back-icon { .zaui-header .zaui-header-back-icon {
color: rgb(var(--color-clay-text)); 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"; import axios from "axios";
interface ErrorResponseBody { export class LocalizedError extends Error {}
message?: string;
}
export function getErrorMessage(error: unknown, fallback: string): string { export function getErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError<ErrorResponseBody>(error)) { if (axios.isAxiosError(error)) {
return error.response?.data?.message || fallback; // 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; return error.message;
} }
......
...@@ -3,27 +3,27 @@ import { Wallet } from "@/types/wallet"; ...@@ -3,27 +3,27 @@ import { Wallet } from "@/types/wallet";
export const WALLET_COLORS = ["#8B7CF6", "#60A5FA", "#34D399", "#FBBF24", "#FB7185"] as const; export const WALLET_COLORS = ["#8B7CF6", "#60A5FA", "#34D399", "#FBBF24", "#FB7185"] as const;
export const WALLET_ICONS = [ export const WALLET_ICONS = [
{ value: "wallet", label: "Ví" }, { value: "wallet", labelKey: "wallet.icons.wallet" },
{ value: "cash", label: "Tiền mặt" }, { value: "cash", labelKey: "wallet.icons.cash" },
{ value: "bank", label: "Ngân hàng" }, { value: "bank", labelKey: "wallet.icons.bank" },
{ value: "card", label: "Thẻ" }, { value: "card", labelKey: "wallet.icons.card" },
{ value: "savings", label: "Tiết kiệm" }, { value: "savings", labelKey: "wallet.icons.savings" },
] as const; ] as const;
export function formatWalletBalance(balance: string, currency: string): string { export function formatWalletBalance(balance: string, currency: string, locale: string): string {
const numericBalance = Number(balance); const numericBalance = Number(balance);
if (!Number.isFinite(numericBalance)) { if (!Number.isFinite(numericBalance)) {
return `${balance} ${currency}`; return `${balance} ${currency}`;
} }
try { try {
return new Intl.NumberFormat("vi-VN", { return new Intl.NumberFormat(locale, {
style: "currency", style: "currency",
currency, currency,
maximumFractionDigits: currency === "VND" ? 0 : 2, maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(numericBalance); }).format(numericBalance);
} catch { } 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 { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
...@@ -8,18 +8,22 @@ import { Card } from "@/components/ui/Card"; ...@@ -8,18 +8,22 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons"; import { IconGradients } from "@/components/ui/icons";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
const forgotPasswordSchema = z.object({ const createForgotPasswordSchema = (t: TranslationFunction) => z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"), 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 ForgotPasswordPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSent, setIsSent] = useState(false); const [isSent, setIsSent] = useState(false);
const { t } = useI18n();
const forgotPasswordSchema = useMemo(() => createForgotPasswordSchema(t), [t]);
const { const {
register, register,
...@@ -36,19 +40,19 @@ const ForgotPasswordPage: React.FC = () => { ...@@ -36,19 +40,19 @@ const ForgotPasswordPage: React.FC = () => {
if (response.success) { if (response.success) {
openSnackbar({ openSnackbar({
type: "success", 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); setIsSent(true);
} else { } else {
openSnackbar({ openSnackbar({
type: "error", 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({ openSnackbar({
type: "error", 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 { } finally {
setIsLoading(false); setIsLoading(false);
...@@ -57,13 +61,13 @@ const ForgotPasswordPage: React.FC = () => { ...@@ -57,13 +61,13 @@ const ForgotPasswordPage: React.FC = () => {
return ( return (
<Page className="page flex flex-col justify-center py-12"> <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 /> <IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Quên Mật Khẩu?</h1> <h1 className="clay-title-h1 text-clay-primary">{t("auth.forgot.title")}</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> <p className="clay-caption">{t("auth.forgot.subtitle")}</p>
</div> </div>
{isSent ? ( {isSent ? (
...@@ -73,12 +77,12 @@ const ForgotPasswordPage: React.FC = () => { ...@@ -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" /> <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> </svg>
</div> </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"> <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> </p>
<Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}> <Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
Nhập mã đặt lại mật khẩu {t("auth.forgot.enterResetCode")}
</Button> </Button>
</Card> </Card>
) : ( ) : (
...@@ -86,7 +90,7 @@ const ForgotPasswordPage: React.FC = () => { ...@@ -86,7 +90,7 @@ const ForgotPasswordPage: React.FC = () => {
<Card> <Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input <Input
label="Email liên kết" label={t("auth.forgot.linkedEmail")}
placeholder="user@gmail.com" placeholder="user@gmail.com"
type="email" type="email"
error={errors.email?.message} error={errors.email?.message}
...@@ -98,22 +102,22 @@ const ForgotPasswordPage: React.FC = () => { ...@@ -98,22 +102,22 @@ const ForgotPasswordPage: React.FC = () => {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <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> <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> </div>
) : ( ) : (
"Gửi Yêu Cầu" t("auth.forgot.submit")
)} )}
</Button> </Button>
</form> </form>
</Card> </Card>
<div className="text-center text-sm font-nunito text-clay-text-muted"> <div className="text-center text-sm font-nunito text-clay-text-muted">
Nhớ mật khẩu?{" "} {t("auth.forgot.rememberPassword")}{" "}
<span <span
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer" className="text-clay-primary font-bold hover:underline cursor-pointer"
> >
Đăng nhập {t("auth.forgot.login")}
</span> </span>
</div> </div>
</> </>
......
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui"; import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
...@@ -10,6 +10,7 @@ import { authService } from "@/services/auth.service"; ...@@ -10,6 +10,7 @@ import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons"; import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail"; const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
...@@ -33,13 +34,13 @@ const updateRememberedEmail = (email: string, shouldRemember: boolean): void => ...@@ -33,13 +34,13 @@ const updateRememberedEmail = (email: string, shouldRemember: boolean): void =>
} }
}; };
const loginSchema = z.object({ const createLoginSchema = (t: TranslationFunction) => z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"), email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
password: z.string().min(8, "Mật khẩu phải từ 8 ký tự trở lên"), password: z.string().min(8, t("validation.passwordMin")),
rememberMe: z.boolean(), rememberMe: z.boolean(),
}); });
type LoginFormValues = z.infer<typeof loginSchema>; type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -48,6 +49,8 @@ const LoginPage: React.FC = () => { ...@@ -48,6 +49,8 @@ const LoginPage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail); const [rememberedEmail] = useState(getRememberedEmail);
const { t } = useI18n();
const loginSchema = useMemo(() => createLoginSchema(t), [t]);
const { const {
register, register,
...@@ -74,19 +77,19 @@ const LoginPage: React.FC = () => { ...@@ -74,19 +77,19 @@ const LoginPage: React.FC = () => {
setAuth(response.data.user, "", ""); setAuth(response.data.user, "", "");
openSnackbar({ openSnackbar({
type: "success", type: "success",
text: "Đăng nhập thành công! 🎉", text: t("auth.login.success"),
}); });
navigate("/", { replace: true }); navigate("/", { replace: true });
} else { } else {
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: response.message || "Đăng nhập thất bại", text: t("auth.login.failed"),
}); });
} }
} catch (error: unknown) { } catch (error: unknown) {
openSnackbar({ openSnackbar({
type: "error", 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 { } finally {
setIsLoading(false); setIsLoading(false);
...@@ -95,19 +98,19 @@ const LoginPage: React.FC = () => { ...@@ -95,19 +98,19 @@ const LoginPage: React.FC = () => {
return ( return (
<Page className="page flex flex-col justify-center py-12"> <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 /> <IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào Mừng!</h1> <h1 className="clay-title-h1 text-clay-primary">{t("auth.login.title")}</h1>
<p className="clay-caption">Đăng nhập tài khoản FinWise của bạn để bắt đầu</p> <p className="clay-caption">{t("auth.login.subtitle")}</p>
</div> </div>
<Card> <Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input <Input
label="Email" label={t("auth.email")}
placeholder="user@gmail.com" placeholder="user@gmail.com"
type="email" type="email"
autoComplete="email" autoComplete="email"
...@@ -117,7 +120,7 @@ const LoginPage: React.FC = () => { ...@@ -117,7 +120,7 @@ const LoginPage: React.FC = () => {
/> />
<Input <Input
label="Mật khẩu" label={t("auth.password")}
placeholder="••••••••" placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"} type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password" autoComplete="current-password"
...@@ -127,7 +130,7 @@ const LoginPage: React.FC = () => { ...@@ -127,7 +130,7 @@ const LoginPage: React.FC = () => {
endAdornment={ endAdornment={
<button <button
type="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} aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)} onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isLoading} disabled={isLoading}
...@@ -146,13 +149,13 @@ const LoginPage: React.FC = () => { ...@@ -146,13 +149,13 @@ const LoginPage: React.FC = () => {
disabled={isLoading} 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" 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> </label>
<span <span
onClick={() => navigate("/forgot-password")} onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer" 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> </span>
</div> </div>
...@@ -160,22 +163,22 @@ const LoginPage: React.FC = () => { ...@@ -160,22 +163,22 @@ const LoginPage: React.FC = () => {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <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> <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> </div>
) : ( ) : (
"Đăng Nhập" t("auth.login.submit")
)} )}
</Button> </Button>
</form> </form>
</Card> </Card>
<div className="text-center text-sm font-nunito text-clay-text-muted"> <div className="text-center text-sm font-nunito text-clay-text-muted">
Chưa có tài khoản?{" "} {t("auth.login.noAccount")}{" "}
<span <span
onClick={() => navigate("/register")} onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer" className="text-clay-primary font-bold hover:underline cursor-pointer"
> >
Đăng ký ngay {t("auth.login.registerNow")}
</span> </span>
</div> </div>
</div> </div>
......
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui"; import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
...@@ -8,38 +8,42 @@ import { Card } from "@/components/ui/Card"; ...@@ -8,38 +8,42 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons"; 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({ .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 email: z
.string() .string()
.min(1, "Email không được để trống") .min(1, t("validation.emailRequired"))
.email("Email không hợp lệ") .email(t("validation.emailInvalid"))
.refine((val) => val.endsWith("@gmail.com"), { .refine((val) => val.endsWith("@gmail.com"), {
message: "Chỉ chấp nhận địa chỉ email @gmail.com", message: t("validation.gmailOnly"),
}), }),
password: z password: z
.string() .string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên") .min(8, t("validation.passwordMin"))
.regex( .regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, /^(?=.*[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, { .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"], path: ["confirmPassword"],
}); });
type RegisterFormValues = z.infer<typeof registerSchema>; type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
const RegisterPage: React.FC = () => { const RegisterPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false); const [isRegistered, setIsRegistered] = useState(false);
const { t } = useI18n();
const registerSchema = useMemo(() => createRegisterSchema(t), [t]);
const { const {
register, register,
...@@ -61,19 +65,19 @@ const RegisterPage: React.FC = () => { ...@@ -61,19 +65,19 @@ const RegisterPage: React.FC = () => {
if (response.success) { if (response.success) {
openSnackbar({ openSnackbar({
type: "success", 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); setIsRegistered(true);
} else { } else {
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: response.message || "Đăng ký thất bại", text: t("auth.register.failed"),
}); });
} }
} catch (error: any) { } catch (error: unknown) {
openSnackbar({ openSnackbar({
type: "error", 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 { } finally {
setIsLoading(false); setIsLoading(false);
...@@ -82,13 +86,13 @@ const RegisterPage: React.FC = () => { ...@@ -82,13 +86,13 @@ const RegisterPage: React.FC = () => {
return ( return (
<Page className="page flex flex-col justify-center py-12"> <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 /> <IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Tạo Tài Khoản</h1> <h1 className="clay-title-h1 text-clay-primary">{t("auth.register.title")}</h1>
<p className="clay-caption">Khởi đầu hành trình quản lý tài chính thông minh</p> <p className="clay-caption">{t("auth.register.subtitle")}</p>
</div> </div>
{isRegistered ? ( {isRegistered ? (
...@@ -98,12 +102,12 @@ const RegisterPage: React.FC = () => { ...@@ -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" /> <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> </svg>
</div> </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"> <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> </p>
<Button variant="primary" fullWidth onClick={() => navigate("/login")}> <Button variant="primary" fullWidth onClick={() => navigate("/login")}>
Quay lại Đăng Nhập {t("auth.register.backToLogin")}
</Button> </Button>
</Card> </Card>
) : ( ) : (
...@@ -111,8 +115,8 @@ const RegisterPage: React.FC = () => { ...@@ -111,8 +115,8 @@ const RegisterPage: React.FC = () => {
<Card> <Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input <Input
label="Họ và tên" label={t("auth.register.fullName")}
placeholder="Nguyễn Văn A" placeholder={t("auth.register.fullNamePlaceholder")}
type="text" type="text"
error={errors.fullName?.message} error={errors.fullName?.message}
{...register("fullName")} {...register("fullName")}
...@@ -120,7 +124,7 @@ const RegisterPage: React.FC = () => { ...@@ -120,7 +124,7 @@ const RegisterPage: React.FC = () => {
/> />
<Input <Input
label="Email (chỉ nhận @gmail.com)" label={t("auth.register.gmailLabel")}
placeholder="user@gmail.com" placeholder="user@gmail.com"
type="email" type="email"
error={errors.email?.message} error={errors.email?.message}
...@@ -129,7 +133,7 @@ const RegisterPage: React.FC = () => { ...@@ -129,7 +133,7 @@ const RegisterPage: React.FC = () => {
/> />
<Input <Input
label="Mật khẩu" label={t("auth.password")}
placeholder="••••••••" placeholder="••••••••"
type="password" type="password"
error={errors.password?.message} error={errors.password?.message}
...@@ -138,7 +142,7 @@ const RegisterPage: React.FC = () => { ...@@ -138,7 +142,7 @@ const RegisterPage: React.FC = () => {
/> />
<Input <Input
label="Xác nhận mật khẩu" label={t("auth.register.confirmPassword")}
placeholder="••••••••" placeholder="••••••••"
type="password" type="password"
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
...@@ -150,22 +154,22 @@ const RegisterPage: React.FC = () => { ...@@ -150,22 +154,22 @@ const RegisterPage: React.FC = () => {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <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> <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> </div>
) : ( ) : (
"Đăng Ký" t("auth.register.submit")
)} )}
</Button> </Button>
</form> </form>
</Card> </Card>
<div className="text-center text-sm font-nunito text-clay-text-muted"> <div className="text-center text-sm font-nunito text-clay-text-muted">
Đã có tài khoản?{" "} {t("auth.register.hasAccount")}{" "}
<span <span
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer" className="text-clay-primary font-bold hover:underline cursor-pointer"
> >
Đăng nhập {t("auth.register.login")}
</span> </span>
</div> </div>
</> </>
......
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui"; import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
...@@ -8,30 +8,34 @@ import { Card } from "@/components/ui/Card"; ...@@ -8,30 +8,34 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons"; 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({ .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 newPassword: z
.string() .string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên") .min(8, t("validation.passwordMin"))
.regex( .regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, /^(?=.*[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, { .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"], path: ["confirmPassword"],
}); });
type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>; type ResetPasswordFormValues = z.infer<ReturnType<typeof createResetPasswordSchema>>;
const ResetPasswordPage: React.FC = () => { const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useI18n();
const resetPasswordSchema = useMemo(() => createResetPasswordSchema(t), [t]);
const { const {
register, register,
...@@ -52,19 +56,19 @@ const ResetPasswordPage: React.FC = () => { ...@@ -52,19 +56,19 @@ const ResetPasswordPage: React.FC = () => {
if (response.success) { if (response.success) {
openSnackbar({ openSnackbar({
type: "success", 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 }); navigate("/login", { replace: true });
} else { } else {
openSnackbar({ openSnackbar({
type: "error", 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({ openSnackbar({
type: "error", 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 { } finally {
setIsLoading(false); setIsLoading(false);
...@@ -73,19 +77,19 @@ const ResetPasswordPage: React.FC = () => { ...@@ -73,19 +77,19 @@ const ResetPasswordPage: React.FC = () => {
return ( return (
<Page className="page flex flex-col justify-center py-12"> <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 /> <IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Đặt Lại Mật Khẩu</h1> <h1 className="clay-title-h1 text-clay-primary">{t("auth.reset.title")}</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> <p className="clay-caption">{t("auth.reset.subtitle")}</p>
</div> </div>
<Card> <Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input <Input
label="Mã khôi phục (Token UUID)" label={t("auth.reset.token")}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
type="text" type="text"
error={errors.token?.message} error={errors.token?.message}
...@@ -94,7 +98,7 @@ const ResetPasswordPage: React.FC = () => { ...@@ -94,7 +98,7 @@ const ResetPasswordPage: React.FC = () => {
/> />
<Input <Input
label="Mật khẩu mới" label={t("auth.reset.newPassword")}
placeholder="••••••••" placeholder="••••••••"
type="password" type="password"
error={errors.newPassword?.message} error={errors.newPassword?.message}
...@@ -103,7 +107,7 @@ const ResetPasswordPage: React.FC = () => { ...@@ -103,7 +107,7 @@ const ResetPasswordPage: React.FC = () => {
/> />
<Input <Input
label="Xác nhận mật khẩu mới" label={t("auth.reset.confirmNewPassword")}
placeholder="••••••••" placeholder="••••••••"
type="password" type="password"
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
...@@ -115,22 +119,22 @@ const ResetPasswordPage: React.FC = () => { ...@@ -115,22 +119,22 @@ const ResetPasswordPage: React.FC = () => {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <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> <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> </div>
) : ( ) : (
"Xác Nhận Đổi Mật Khẩu" t("auth.reset.submit")
)} )}
</Button> </Button>
</form> </form>
</Card> </Card>
<div className="text-center text-sm font-nunito text-clay-text-muted"> <div className="text-center text-sm font-nunito text-clay-text-muted">
Quay lại{" "} {t("auth.reset.back")}{" "}
<span <span
onClick={() => navigate("/login")} onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer" className="text-clay-primary font-bold hover:underline cursor-pointer"
> >
Đăng nhập {t("auth.reset.login")}
</span> </span>
</div> </div>
</div> </div>
......
...@@ -5,14 +5,16 @@ import { Card } from "@/components/ui/Card"; ...@@ -5,14 +5,16 @@ import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients, WalletIcon } from "@/components/ui/icons"; import { AIAssistantIcon, IconGradients, WalletIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore(); const { user } = useAuthStore();
const { t } = useI18n();
return ( return (
<Page className="page flex flex-col justify-between py-8"> <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 /> <IconGradients />
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4"> <div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
...@@ -32,18 +34,18 @@ function HomePage() { ...@@ -32,18 +34,18 @@ function HomePage() {
{/* Text Area */} {/* Text Area */}
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào, {user?.fullName || "Người dùng"}!</h1> <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">Sổ tay Chi tiêu & Báo cáo Tài chính</h2> <h2 className="clay-title-h3 text-clay-text">{t("home.subtitle")}</h2>
<p className="clay-caption max-w-xs mx-auto"> <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> </p>
</div> </div>
{/* Info Card */} {/* Info Card */}
<Card className="w-full max-w-sm"> <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"> <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> </p>
</Card> </Card>
</div> </div>
...@@ -56,21 +58,21 @@ function HomePage() { ...@@ -56,21 +58,21 @@ function HomePage() {
onClick={() => navigate("/wallets")} onClick={() => navigate("/wallets")}
className="gap-2" className="gap-2"
> >
Quản lý ví {t("home.wallets")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
onClick={() => navigate("/profile")} onClick={() => navigate("/profile")}
> >
Trang cá nhân của tôi {t("home.profile")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
onClick={() => navigate("/style-guide")} onClick={() => navigate("/style-guide")}
> >
Khám phá Style Guide {t("home.styleGuide")}
</Button> </Button>
</div> </div>
</Page> </Page>
......
...@@ -2,6 +2,7 @@ import React, { PointerEvent, useEffect, useRef, useState } from "react"; ...@@ -2,6 +2,7 @@ import React, { PointerEvent, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useI18n } from "@/i18n";
export interface AvatarPosition { export interface AvatarPosition {
x: number; x: number;
...@@ -62,7 +63,10 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({ ...@@ -62,7 +63,10 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
decreaseLabel, decreaseLabel,
increaseLabel, increaseLabel,
onChange, onChange,
}) => ( }) => {
const { t } = useI18n();
return (
<div <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" : ""}`} 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} title={locked ? lockedMessage : undefined}
...@@ -71,7 +75,7 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({ ...@@ -71,7 +75,7 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
{label} {label}
{locked && ( {locked && (
<svg <svg
aria-label="Đang khóa" aria-label={t("avatarEditor.locked")}
viewBox="0 0 24 24" viewBox="0 0 24 24"
className="h-3.5 w-3.5 text-clay-text-muted" className="h-3.5 w-3.5 text-clay-text-muted"
fill="none" fill="none"
...@@ -121,7 +125,8 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({ ...@@ -121,7 +125,8 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
{displayValue} {displayValue}
</span> </span>
</div> </div>
); );
};
export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
isOpen, isOpen,
...@@ -135,6 +140,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -135,6 +140,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onSave, onSave,
onDelete, onDelete,
}) => { }) => {
const { t } = useI18n();
const dragStart = useRef<DragStart | null>(null); const dragStart = useRef<DragStart | null>(null);
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null); const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
const canAdjustHorizontal = imageSize !== null const canAdjustHorizontal = imageSize !== null
...@@ -185,7 +191,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -185,7 +191,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={isSaving ? () => undefined : onClose} onClose={isSaving ? () => undefined : onClose}
title="Chỉnh ảnh đại diện" title={t("avatarEditor.title")}
footer={ footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2"> <div className="flex w-full flex-wrap items-center justify-end gap-2">
{hasStoredAvatar && ( {hasStoredAvatar && (
...@@ -196,7 +202,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -196,7 +202,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onDelete} onClick={onDelete}
className="mr-auto px-3 text-sm text-clay-expense" className="mr-auto px-3 text-sm text-clay-expense"
> >
Xóa ảnh {t("avatarEditor.delete")}
</Button> </Button>
)} )}
<Button <Button
...@@ -206,7 +212,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -206,7 +212,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onClose} onClick={onClose}
className="px-3 text-sm" className="px-3 text-sm"
> >
Hủy {t("common.cancel")}
</Button> </Button>
<Button <Button
type="button" type="button"
...@@ -215,7 +221,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -215,7 +221,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onClick={onSave} onClick={onSave}
className="px-4 text-sm" className="px-4 text-sm"
> >
{isSaving ? "Đang lưu..." : "Lưu ảnh"} {isSaving ? t("common.saving") : t("avatarEditor.save")}
</Button> </Button>
</div> </div>
} }
...@@ -223,7 +229,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -223,7 +229,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div <div
role="application" role="application"
aria-label="Kéo để chọn vùng ảnh đại diện" aria-label={t("avatarEditor.dragLabel")}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={stopDragging} onPointerUp={stopDragging}
...@@ -233,7 +239,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -233,7 +239,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
alt="Xem trước ảnh đại diện" alt={t("avatarEditor.previewAlt")}
draggable={false} draggable={false}
className="h-full w-full pointer-events-none rounded-full object-cover" className="h-full w-full pointer-events-none rounded-full object-cover"
style={{ style={{
...@@ -246,19 +252,19 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -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"> <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>
)} )}
</div> </div>
<p className="clay-caption text-center"> <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> </p>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<AvatarAdjustmentControl <AvatarAdjustmentControl
id="avatar-position-x" id="avatar-position-x"
label="Ngang" label={t("avatarEditor.horizontal")}
value={position.x} value={position.x}
displayValue={`${position.x}%`} displayValue={`${position.x}%`}
min={0} min={0}
...@@ -267,14 +273,14 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -267,14 +273,14 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
buttonStep={5} buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustHorizontal} disabled={!imageUrl || isSaving || !canAdjustHorizontal}
locked={Boolean(imageUrl && imageSize && !canAdjustHorizontal)} locked={Boolean(imageUrl && imageSize && !canAdjustHorizontal)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều ngang." lockedMessage={t("avatarEditor.horizontalLocked")}
decreaseLabel="Dịch ảnh sang trái" decreaseLabel={t("avatarEditor.moveLeft")}
increaseLabel="Dịch ảnh sang phải" increaseLabel={t("avatarEditor.moveRight")}
onChange={(x) => onPositionChange({ ...position, x })} onChange={(x) => onPositionChange({ ...position, x })}
/> />
<AvatarAdjustmentControl <AvatarAdjustmentControl
id="avatar-position-y" id="avatar-position-y"
label="Dọc" label={t("avatarEditor.vertical")}
value={position.y} value={position.y}
displayValue={`${position.y}%`} displayValue={`${position.y}%`}
min={0} min={0}
...@@ -283,15 +289,15 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -283,15 +289,15 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
buttonStep={5} buttonStep={5}
disabled={!imageUrl || isSaving || !canAdjustVertical} disabled={!imageUrl || isSaving || !canAdjustVertical}
locked={Boolean(imageUrl && imageSize && !canAdjustVertical)} locked={Boolean(imageUrl && imageSize && !canAdjustVertical)}
lockedMessage="Tỷ lệ ảnh không có vùng dư để chỉnh theo chiều dọc." lockedMessage={t("avatarEditor.verticalLocked")}
decreaseLabel="Dịch ảnh lên trên" decreaseLabel={t("avatarEditor.moveUp")}
increaseLabel="Dịch ảnh xuống dưới" increaseLabel={t("avatarEditor.moveDown")}
onChange={(y) => onPositionChange({ ...position, y })} onChange={(y) => onPositionChange({ ...position, y })}
/> />
</div> </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"> <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 <input
type="file" type="file"
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp"
...@@ -304,7 +310,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({ ...@@ -304,7 +310,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
}} }}
/> />
</label> </label>
<span className="clay-caption">JPEG, PNG, WebP · tối đa 5 MB</span> <span className="clay-caption">{t("avatarEditor.fileHint")}</span>
</div> </div>
</Modal> </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